From 3886e585dd092cb97323693d7f49c8f8884bc23f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 14:59:16 -0700 Subject: [PATCH] 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. --- .changeset/fn-7143-ce-workflow-progress.md | 7 ++ packages/core/src/types.ts | 2 + .../app/utils/__tests__/taskProgress.test.ts | 40 +++++++++ packages/dashboard/app/utils/taskProgress.ts | 22 ++++- ...iltin-coding-workflow-step-results.test.ts | 53 +++++++++++ .../engine/src/workflow-graph-executor.ts | 89 ++++++++++++++++--- 6 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-7143-ce-workflow-progress.md diff --git a/.changeset/fn-7143-ce-workflow-progress.md b/.changeset/fn-7143-ce-workflow-progress.md new file mode 100644 index 0000000000..d275784a24 --- /dev/null +++ b/.changeset/fn-7143-ce-workflow-progress.md @@ -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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7921b04716..dadd3c4a39 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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.) */ diff --git a/packages/dashboard/app/utils/__tests__/taskProgress.test.ts b/packages/dashboard/app/utils/__tests__/taskProgress.test.ts index 8cb69b8ce0..b46e6d4409 100644 --- a/packages/dashboard/app/utils/__tests__/taskProgress.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskProgress.test.ts @@ -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({ diff --git a/packages/dashboard/app/utils/taskProgress.ts b/packages/dashboard/app/utils/taskProgress.ts index aae4c89447..ace87d3fb4 100644 --- a/packages/dashboard/app/utils/taskProgress.ts +++ b/packages/dashboard/app/utils/taskProgress.ts @@ -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; diff --git a/packages/engine/src/__tests__/builtin-coding-workflow-step-results.test.ts b/packages/engine/src/__tests__/builtin-coding-workflow-step-results.test.ts index 1239266f5d..8498a9673b 100644 --- a/packages/engine/src/__tests__/builtin-coding-workflow-step-results.test.ts +++ b/packages/engine/src/__tests__/builtin-coding-workflow-step-results.test.ts @@ -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({ diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index c7f37ecc06..22011c7bde 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -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, workflow: WorkflowIr, signal?: AbortSignal, + recordProgress = true, ): Promise { 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 { + 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 { + 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 {