From 0dcdc6d1358ce5c42e703005f04fe5f4ca5eda38 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 11:21:33 +0200 Subject: [PATCH] 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) --- src/app/models/task-execution.spec.ts | 39 ++++++++ src/app/models/task-execution.ts | 31 ++++++- .../task-executions/task-executions-call.ts | 5 +- .../task-execution-viewer.css | 91 +++++++++++++++++++ .../task-execution-viewer.html | 33 +++++++ .../task-execution-viewer.ts | 25 ++++- 6 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 src/app/models/task-execution.spec.ts diff --git a/src/app/models/task-execution.spec.ts b/src/app/models/task-execution.spec.ts new file mode 100644 index 0000000..9cafedd --- /dev/null +++ b/src/app/models/task-execution.spec.ts @@ -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(); + }); +}); diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index b748cac..37182a4 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -112,9 +112,38 @@ export type TaskExecutionContext = { executionVariables?: Record; executionVariableDescriptors?: Record; errorCodes?: Record; - 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 => + !!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; diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index eb25fa1..88b8f0e 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -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'] }; } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index c7bc289..b94e311 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -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; +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index bfec44c..5485b68 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -95,6 +95,39 @@ } + @if (outcomes().length) { +
+
+ + {{ outcomes().length === 1 ? 'Outcome' : 'Outcomes' }} +
+ + @for (outcome of outcomes(); track outcome.stepId + outcome.timestamp) { +
+
+ {{ outcome.code }} + {{ outcome.label }} + via {{ stepNameForOutcome(outcome.stepId) }} +
+ + @if (outcome.payload !== null && outcome.payload !== undefined) { +
+ @if (isTextPayload(outcome.payload)) { +
{{ outcome.payload }}
+ } @else { + + } +
+ } @else { +
+ This End node received no value, so the outcome carries only its label. +
+ } +
+ } +
+ } +
Execution Graph (read-only)
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index df47459..318a4d8 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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 '-';