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:
parent
4fa449658e
commit
0dcdc6d135
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 '-';
|
||||
|
|
|
|||
Loading…
Reference in New Issue