Show a run's outcomes, where an End node puts the flow's answer

A flow result is built only from *unconnected* outputs, so wiring the last block
into an End node moves its value out of the result and into the outcome payload -
which nothing in the UI read. The run then looked like it produced nothing at all.
There is already an execution in a local database whose outcome payload is a full
generated rejection email that was invisible for exactly this reason.

The run view now has an Outcomes section above the graph, listing each End the run
passed through: its code, its label, the step it came from, and its payload. An
End reached with no value says so, rather than showing an empty box - the two
cases mean different things.

A text payload renders as text rather than through the JSON tree. The tree would
have kept the line breaks but wraps strings in quotes, and the common case here is
a generated document, not a data structure.

This is the smallest of the options for the underlying gap: the data was already
persisted and already in the API payload, so nothing in the engine changed. The
gap itself remains - End cannot be attached as a pure ordering dependency
(canDependOnOtherNodes is false), so a single-exit flow still has to choose
between a labelled end and a value in `result`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 11:21:33 +02:00
parent 4fa449658e
commit 0dcdc6d135
6 changed files with 220 additions and 4 deletions

View File

@ -0,0 +1,39 @@
import { normalizeExecutionOutcomes } from './task-execution';
describe('normalizeExecutionOutcomes', () => {
it('maps the backend outcome shape', () => {
const outcomes = normalizeExecutionOutcomes([
{ stepId: 's1', code: 'APPROVED', label: 'Approved', payload: 'the answer', timestamp: 42 }
]);
expect(outcomes).toEqual([
{ stepId: 's1', code: 'APPROVED', label: 'Approved', payload: 'the answer', timestamp: 42 }
]);
});
it('falls back to the code when no label is given', () => {
expect(normalizeExecutionOutcomes([{ code: 'DONE' }])[0].label).toBe('DONE');
});
it('keeps an outcome that carries a payload but no code', () => {
// The payload is the flow's answer, so it must never be dropped for want of a label.
const outcomes = normalizeExecutionOutcomes([{ payload: { value: 1 } }]);
expect(outcomes).toHaveLength(1);
expect(outcomes[0].payload).toEqual({ value: 1 });
});
it('drops entries that carry neither a code nor a payload', () => {
expect(normalizeExecutionOutcomes([{}, null, 'nope'])).toEqual([]);
});
it('returns nothing when the field is absent or not a list', () => {
expect(normalizeExecutionOutcomes(undefined)).toEqual([]);
expect(normalizeExecutionOutcomes({ code: 'X' })).toEqual([]);
});
it('normalizes a missing payload to null rather than undefined', () => {
// The template distinguishes "no value reached this End" from "the value was null".
expect(normalizeExecutionOutcomes([{ code: 'DONE' }])[0].payload).toBeNull();
});
});

View File

@ -112,9 +112,38 @@ export type TaskExecutionContext = {
executionVariables?: Record<string, unknown>;
executionVariableDescriptors?: Record<string, unknown>;
errorCodes?: Record<string, unknown>;
outcomes?: unknown[];
/**
* One entry per End node the run passed through. This is where a flow's answer lands when the
* last block is wired into an End: the flow `result` is built only from *unconnected* outputs, so
* connecting one into an End moves its value here, as the outcome payload.
*/
outcomes?: TaskExecutionOutcome[];
};
export type TaskExecutionOutcome = {
stepId: string;
code: string;
label: string;
payload: unknown;
timestamp: number;
};
export function normalizeExecutionOutcomes(raw: unknown): TaskExecutionOutcome[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((entry): entry is Record<string, unknown> =>
!!entry && typeof entry === 'object' && !Array.isArray(entry))
.map((entry) => ({
stepId: String(entry['stepId'] ?? ''),
code: String(entry['code'] ?? ''),
label: String(entry['label'] ?? entry['code'] ?? ''),
payload: entry['payload'] ?? null,
timestamp: typeof entry['timestamp'] === 'number' ? entry['timestamp'] : 0
}))
.filter((outcome) => outcome.code.length > 0 || outcome.payload !== null);
}
export type TaskExecutionGlobalInputDescriptor = {
name: string;
kind: string;

View File

@ -13,7 +13,7 @@ import {
BiasRerunRequest,
BiasRoutingChangeEntry
} from '@models/bias-impact';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup, normalizeExecutionOutcomes } from '@models/task-execution';
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
import { map, Observable } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -347,7 +347,8 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
context: {
...(context as TaskExecution['context']),
globalInputs,
globalInputDescriptors
globalInputDescriptors,
outcomes: normalizeExecutionOutcomes(context['outcomes'])
} as TaskExecution['context']
};
}

View File

@ -505,3 +505,94 @@
.execution-bias-legend-dot-changed { background: #b45309; }
.execution-bias-legend-reset { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: .4rem; color: #0f172a; cursor: pointer; font-size: .76rem; font-weight: 600; padding: .25rem .55rem; }
.execution-bias-legend-reset:hover { background: #e2e8f0; }
/*
* A flow whose last block is wired into an End node puts its answer here rather than in the flow
* result, so this reads as the run's conclusion, not as a footnote.
*/
.execution-outcomes {
margin: 0.25rem 0.25rem 0;
padding: 0.5rem 0.75rem 0.625rem;
border: 1px solid #bbf7d0;
border-radius: 6px;
background: #f0fdf4;
}
.execution-outcomes-title {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.375rem;
color: #166534;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.execution-outcomes-title .mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.execution-outcome + .execution-outcome {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed #bbf7d0;
}
.execution-outcome-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.375rem;
}
.execution-outcome-code {
padding: 0 0.375rem;
border-radius: 999px;
background: #16a34a;
color: #ffffff;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.execution-outcome-label {
color: #14532d;
font-size: 0.875rem;
font-weight: 600;
}
.execution-outcome-step {
color: #4d7c5f;
font-size: 0.75rem;
}
.execution-outcome-payload {
margin-top: 0.375rem;
padding: 0.375rem 0.5rem;
border: 1px solid #dcfce7;
border-radius: 4px;
background: #ffffff;
max-height: 220px;
overflow: auto;
}
.execution-outcome-empty {
margin-top: 0.25rem;
color: #4d7c5f;
font-size: 0.75rem;
font-style: italic;
}
/* Generated documents keep their line breaks; the JSON tree would quote and flatten them. */
.execution-outcome-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 0.8125rem;
line-height: 1.45;
color: #1f2937;
}

View File

@ -95,6 +95,39 @@
<button type="button" mat-stroked-button (click)="openAuthorizationPanel()">Choose credential</button>
</div>
}
@if (outcomes().length) {
<div class="execution-outcomes">
<div class="execution-outcomes-title">
<mat-icon fontIcon="flag"></mat-icon>
<span>{{ outcomes().length === 1 ? 'Outcome' : 'Outcomes' }}</span>
</div>
@for (outcome of outcomes(); track outcome.stepId + outcome.timestamp) {
<div class="execution-outcome">
<div class="execution-outcome-head">
<span class="execution-outcome-code">{{ outcome.code }}</span>
<span class="execution-outcome-label">{{ outcome.label }}</span>
<span class="execution-outcome-step">via {{ stepNameForOutcome(outcome.stepId) }}</span>
</div>
@if (outcome.payload !== null && outcome.payload !== undefined) {
<div class="execution-outcome-payload">
@if (isTextPayload(outcome.payload)) {
<div class="execution-outcome-text">{{ outcome.payload }}</div>
} @else {
<app-json-viewer [value]="outcome.payload" [initiallyExpanded]="true" />
}
</div>
} @else {
<div class="execution-outcome-empty">
This End node received no value, so the outcome carries only its label.
</div>
}
</div>
}
</div>
}
<div class="flex-1 min-h-0 flex gap-2 p-1">
<div class="execution-graph-shell flex-1 border border-slate-200 rounded-md bg-slate-50 min-h-0 overflow-hidden">
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-white">Execution Graph (read-only)</div>

View File

@ -55,6 +55,7 @@ import {
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasImpactReportListComponent } from '@shared/bias-impact-report-list/bias-impact-report-list';
import { JsonViewerComponent } from '@shared/json-viewer/json-viewer';
import { firstValueFrom, Observable, of, take, tap } from 'rxjs';
import {
ExecutionOutputEntry,
@ -95,7 +96,7 @@ import {
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent],
imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent, JsonViewerComponent],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -532,6 +533,28 @@ export class TaskExecutionViewerComponent implements OnDestroy {
};
});
/**
* Where a flow's answer actually lands when its last block is wired into an End node: the flow
* result is built only from unconnected outputs, so that connection moves the value here. Without
* surfacing it the run looks like it produced nothing.
*/
readonly outcomes = computed(() => this.execution()?.context.outcomes ?? []);
readonly hasOutcomePayload = computed(() =>
this.outcomes().some((outcome) => outcome.payload !== null && outcome.payload !== undefined));
/**
* A text payload is shown as text, not through the JSON tree: the common case is a generated
* document - an email, a report - and the tree would quote it and collapse its line breaks.
*/
isTextPayload(payload: unknown): payload is string {
return typeof payload === 'string';
}
stepNameForOutcome(stepId: string): string {
return this.execution()?.context.steps?.[stepId]?.node?.name ?? stepId;
}
readonly formattedDuration = computed(() => {
const context = this.execution()?.context;
if (!context?.startTime || !context?.endTime) return '-';