fix(FN-7143): show compound workflow node progress
Record skill-backed workflow nodes into workflowStepResults and teach task progress surfaces to include those graph-node records without re-showing disabled optional workflow checks.
This commit is contained in:
7
.changeset/fn-7143-ce-workflow-progress.md
Normal file
7
.changeset/fn-7143-ce-workflow-progress.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show Compound Engineering workflow stage progress on task cards and details.
|
||||
category: fix
|
||||
dev: Skill-backed workflow nodes now record graph-node progress separately from optional workflow toggles.
|
||||
@@ -819,6 +819,8 @@ export interface WorkflowStepResult {
|
||||
workflowStepName: string;
|
||||
/** Lifecycle phase at execution time */
|
||||
phase?: WorkflowStepPhase;
|
||||
/** Runtime source for distinguishing graph-authored node progress from optional-toggle checks. */
|
||||
source?: "optional-group" | "node";
|
||||
/** Execution status */
|
||||
status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending";
|
||||
/** Output from the workflow step agent (findings, errors, etc.) */
|
||||
|
||||
@@ -141,6 +141,46 @@ describe("getUnifiedTaskProgress", () => {
|
||||
expect(progress.items.some((i) => i.id === "workflow-disabled-step")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes recorded graph-node progress that is not an optional toggle", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: [],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "plan",
|
||||
workflowStepName: "Plan",
|
||||
source: "node",
|
||||
status: "passed",
|
||||
startedAt: "2026-06-29T21:49:55.355Z",
|
||||
completedAt: "2026-06-29T21:51:00.000Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "execute",
|
||||
workflowStepName: "Execute",
|
||||
source: "node",
|
||||
status: "pending",
|
||||
startedAt: "2026-06-29T21:51:00.996Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
source: "optional-group",
|
||||
status: "passed",
|
||||
startedAt: "2026-06-29T21:52:00.000Z",
|
||||
completedAt: "2026-06-29T21:52:20.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.items.map((item) => [item.id, item.name, item.status])).toEqual([
|
||||
["workflow-plan", "Plan", "done"],
|
||||
["workflow-execute", "Execute", "running"],
|
||||
]);
|
||||
expect(progress.total).toBe(2);
|
||||
expect(progress.completed).toBe(1);
|
||||
});
|
||||
|
||||
it("produces 8 items with the correct completed count for 6 impl steps + 2 workflow steps", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Task, WorkflowStepResult, WorkflowStepPhase, StepStatus } from "@f
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25-00:00:
|
||||
Graph-native workflow steps (plan U3). Workflow step status now comes entirely from the graph-written
|
||||
`task.workflowStepResults` entries (keyed by node id === enabledWorkflowSteps[i]); the legacy
|
||||
Graph-native workflow steps (plan U3). Optional workflow step status now comes from graph-written
|
||||
`task.workflowStepResults` entries keyed by node id === enabledWorkflowSteps[i]; top-level workflow nodes can also record explicit `source:"node"` progress for workflows that do not project every stage into `task.steps`. The legacy
|
||||
`/api/workflow-steps` DB-row name lookup was dropped, so step names resolve from `result.workflowStepName`
|
||||
with a fallback to the raw id.
|
||||
|
||||
@@ -15,7 +15,7 @@ Render states (design-lens): the progress model distinguishes
|
||||
- `failed` (blocking gate failure — red)
|
||||
- `skipped`
|
||||
Disabled optional steps are simply absent from `enabledWorkflowSteps`, so they never appear in the
|
||||
counter/bar.
|
||||
counter/bar. Recorded workflow-node progress is included independently because it represents an actual graph stage that ran, not a toggle placeholder.
|
||||
*/
|
||||
|
||||
export type UnifiedTaskProgressStatus = StepStatus | "failed" | "advisory_failure" | "running";
|
||||
@@ -112,6 +112,20 @@ export function getUnifiedTaskProgress(
|
||||
phase: result?.phase ?? "pre-merge",
|
||||
};
|
||||
});
|
||||
const enabledWorkflowStepIds = new Set(task.enabledWorkflowSteps ?? []);
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-06-29-15:05:
|
||||
Compound Engineering runs top-level skill nodes (Plan, Execute, Commit/PR, Resolve feedback) that do real work but are not optional toggles and do not update `task.steps`. Include recorded `source:"node"` results even when `enabledWorkflowSteps` is empty so task cards and detail progress match the graph's actual active stage, while stale disabled optional-group results remain hidden.
|
||||
*/
|
||||
const recordedNodeItems: UnifiedTaskProgressItem[] = (task.workflowStepResults ?? [])
|
||||
.filter((result) => result.source === "node" && !enabledWorkflowStepIds.has(result.workflowStepId))
|
||||
.map((result) => ({
|
||||
id: `workflow-${result.workflowStepId}`,
|
||||
name: resolveWorkflowStepName(result.workflowStepId, result),
|
||||
status: mapWorkflowStatus(result),
|
||||
source: "workflow",
|
||||
phase: result.phase ?? "pre-merge",
|
||||
}));
|
||||
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-06-29-00:41:
|
||||
@@ -119,7 +133,7 @@ export function getUnifiedTaskProgress(
|
||||
*/
|
||||
const preExecutionWorkflowItems = workflowItems.filter((item) => item.id === "workflow-plan-review");
|
||||
const remainingWorkflowItems = workflowItems.filter((item) => item.id !== "workflow-plan-review");
|
||||
const items = [...preExecutionWorkflowItems, ...stepItems, ...remainingWorkflowItems];
|
||||
const items = [...preExecutionWorkflowItems, ...stepItems, ...remainingWorkflowItems, ...recordedNodeItems];
|
||||
const total = items.length;
|
||||
const completed = items.filter((item) => isCompleted(item.status)).length;
|
||||
|
||||
|
||||
@@ -76,6 +76,59 @@ function innerHandler(reviewResult: WorkflowNodeResult): WorkflowNodeHandler {
|
||||
}
|
||||
|
||||
describe("WorkflowGraphExecutor optional-group → task.workflowStepResults (plan U2)", () => {
|
||||
it("records top-level skill node progress so Compound Engineering stages show on cards", async () => {
|
||||
const recorder = makeRecorder();
|
||||
const ir: WorkflowIr = {
|
||||
version: "v2",
|
||||
name: "ce-node-progress-test",
|
||||
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "execute",
|
||||
kind: "prompt",
|
||||
config: {
|
||||
name: "Execute",
|
||||
executor: "skill",
|
||||
skillName: "compound-engineering:ce-work",
|
||||
toolMode: "coding",
|
||||
prompt: "Run /ce-work",
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute" },
|
||||
{ from: "execute", to: "end" },
|
||||
],
|
||||
};
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
handlers: { prompt: async () => ({ outcome: "success" }) },
|
||||
recordWorkflowStepResult: recorder.record,
|
||||
});
|
||||
|
||||
await executor.run(taskWith([]), settingsOn(), ir);
|
||||
|
||||
expect(recorder.calls).toHaveLength(2);
|
||||
expect(recorder.calls[0]).toEqual(expect.objectContaining({
|
||||
workflowStepId: "execute",
|
||||
workflowStepName: "Execute",
|
||||
source: "node",
|
||||
status: "pending",
|
||||
startedAt: expect.any(String),
|
||||
}));
|
||||
expect(recorder.results).toEqual([
|
||||
expect.objectContaining({
|
||||
workflowStepId: "execute",
|
||||
workflowStepName: "Execute",
|
||||
source: "node",
|
||||
status: "passed",
|
||||
startedAt: recorder.calls[0].startedAt,
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("(a) enabled group + APPROVE verdict → one entry keyed by the group node id with status 'passed'", async () => {
|
||||
const recorder = makeRecorder();
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
|
||||
@@ -529,7 +529,7 @@ export class WorkflowGraphExecutor {
|
||||
getLiveSteps: () => this.resolveTaskSteps(task),
|
||||
context,
|
||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false),
|
||||
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||
persistence: this.deps.stepInstancePersistence,
|
||||
onReworkReset: this.deps.onReworkReset,
|
||||
@@ -557,7 +557,7 @@ export class WorkflowGraphExecutor {
|
||||
const loopResult = await runLoop(node, {
|
||||
context,
|
||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false),
|
||||
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||
signal: this.deps.signal,
|
||||
now: this.deps.runLoopNowForTests,
|
||||
@@ -670,6 +670,7 @@ export class WorkflowGraphExecutor {
|
||||
workflowStepName: groupName,
|
||||
phase: stepPhase,
|
||||
status: "pending",
|
||||
source: "optional-group",
|
||||
startedAt: stepStartedAt,
|
||||
});
|
||||
this.deps.logTaskEntry?.(`${logPrefix} Starting workflow step: ${groupName}`);
|
||||
@@ -677,7 +678,7 @@ export class WorkflowGraphExecutor {
|
||||
const groupResult = await runOptionalGroup(node, {
|
||||
context,
|
||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false),
|
||||
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||
signal: this.deps.signal,
|
||||
});
|
||||
@@ -705,6 +706,7 @@ export class WorkflowGraphExecutor {
|
||||
workflowStepId: node.id,
|
||||
workflowStepName: groupName,
|
||||
phase: stepPhase,
|
||||
source: "optional-group",
|
||||
status: stepStatus,
|
||||
...(verdict ? { verdict } : {}),
|
||||
...(stepOutput !== undefined ? { output: stepOutput } : {}),
|
||||
@@ -1127,6 +1129,7 @@ export class WorkflowGraphExecutor {
|
||||
context: Record<string, unknown>,
|
||||
workflow: WorkflowIr,
|
||||
signal?: AbortSignal,
|
||||
recordProgress = true,
|
||||
): Promise<WorkflowNodeResult> {
|
||||
const handler = this.handlers[node.kind];
|
||||
|
||||
@@ -1142,21 +1145,32 @@ export class WorkflowGraphExecutor {
|
||||
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||
try {
|
||||
await this.prepareNodeExecution(node, task);
|
||||
const progressRecord = recordProgress && this.shouldRecordNodeProgress(node)
|
||||
? await this.recordNodeProgressStart(task.id, node)
|
||||
: null;
|
||||
const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal);
|
||||
if (pluginResult) {
|
||||
const projected = await this.publishTaskProjectionFromResult(task.id, node, pluginResult);
|
||||
return signal?.aborted || this.isAbortNodeResult(projected)
|
||||
? this.withEnginePauseAbortContext(node, projected)
|
||||
: projected;
|
||||
if (signal?.aborted || this.isAbortNodeResult(projected)) {
|
||||
return this.withEnginePauseAbortContext(node, projected);
|
||||
}
|
||||
if (progressRecord) {
|
||||
await this.recordNodeProgressFinish(task.id, node, progressRecord, projected);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
if (!handler) {
|
||||
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
|
||||
}
|
||||
const result = await handler(node, { task, settings, context, signal });
|
||||
const projected = await this.publishTaskProjectionFromResult(task.id, node, result);
|
||||
return signal?.aborted || this.isAbortNodeResult(projected)
|
||||
? this.withEnginePauseAbortContext(node, projected)
|
||||
: projected;
|
||||
if (signal?.aborted || this.isAbortNodeResult(projected)) {
|
||||
return this.withEnginePauseAbortContext(node, projected);
|
||||
}
|
||||
if (progressRecord) {
|
||||
await this.recordNodeProgressFinish(task.id, node, progressRecord, projected);
|
||||
}
|
||||
return projected;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||
lastError = error;
|
||||
@@ -1167,13 +1181,68 @@ export class WorkflowGraphExecutor {
|
||||
return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||
}
|
||||
|
||||
return {
|
||||
const failureResult: WorkflowNodeResult = {
|
||||
outcome: "failure",
|
||||
value: "exception",
|
||||
contextPatch: {
|
||||
[`node:${node.id}:error`]: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
},
|
||||
};
|
||||
if (recordProgress && this.shouldRecordNodeProgress(node)) {
|
||||
await this.recordNodeProgressFinish(task.id, node, null, failureResult);
|
||||
}
|
||||
return failureResult;
|
||||
}
|
||||
|
||||
private shouldRecordNodeProgress(node: WorkflowIrNode): boolean {
|
||||
/*
|
||||
* FNXC:WorkflowNodeProgress 2026-06-29-15:05:
|
||||
* Compound Engineering stages are top-level skill prompt/gate nodes, not parsed implementation steps or optional toggles. Record those skill nodes into `task.workflowStepResults` so cards and task details show the active CE stage while avoiding duplicate records for ordinary model prompts and optional-group template internals.
|
||||
*/
|
||||
const skillName = typeof node.config?.skillName === "string" ? node.config.skillName.trim() : "";
|
||||
return skillName.length > 0 && (node.kind === "prompt" || node.kind === "gate");
|
||||
}
|
||||
|
||||
private workflowNodeProgressName(node: WorkflowIrNode): string {
|
||||
const configuredName = typeof node.config?.name === "string" ? node.config.name.trim() : "";
|
||||
return configuredName || node.id;
|
||||
}
|
||||
|
||||
private async recordNodeProgressStart(taskId: string, node: WorkflowIrNode): Promise<WorkflowStepResult | null> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const result: WorkflowStepResult = {
|
||||
workflowStepId: node.id,
|
||||
workflowStepName: this.workflowNodeProgressName(node),
|
||||
phase: node.config?.phase === "post-merge" ? "post-merge" : "pre-merge",
|
||||
source: "node",
|
||||
status: "pending",
|
||||
startedAt,
|
||||
};
|
||||
await this.recordOptionalGroupStepResult(taskId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async recordNodeProgressFinish(
|
||||
taskId: string,
|
||||
node: WorkflowIrNode,
|
||||
started: WorkflowStepResult | null,
|
||||
nodeResult: WorkflowNodeResult,
|
||||
): Promise<void> {
|
||||
const status: WorkflowStepResult["status"] = nodeResult.outcome === "success" ? "passed" : "failed";
|
||||
const contextPatch = nodeResult.contextPatch ?? {};
|
||||
const output = typeof contextPatch.output === "string" ? contextPatch.output : undefined;
|
||||
const notes = typeof contextPatch.notes === "string" ? contextPatch.notes : undefined;
|
||||
await this.recordOptionalGroupStepResult(taskId, {
|
||||
workflowStepId: node.id,
|
||||
workflowStepName: this.workflowNodeProgressName(node),
|
||||
phase: started?.phase ?? (node.config?.phase === "post-merge" ? "post-merge" : "pre-merge"),
|
||||
source: "node",
|
||||
status,
|
||||
...(output !== undefined ? { output } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
startedAt: started?.startedAt ?? new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async prepareNodeExecution(node: WorkflowIrNode, task: TaskDetail): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user