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:
gsxdsm
2026-06-29 14:59:16 -07:00
parent 167d242ad5
commit 3886e585dd
6 changed files with 199 additions and 14 deletions

View 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.

View File

@@ -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.) */

View File

@@ -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({

View File

@@ -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;

View File

@@ -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({

View File

@@ -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> {