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;
|
workflowStepName: string;
|
||||||
/** Lifecycle phase at execution time */
|
/** Lifecycle phase at execution time */
|
||||||
phase?: WorkflowStepPhase;
|
phase?: WorkflowStepPhase;
|
||||||
|
/** Runtime source for distinguishing graph-authored node progress from optional-toggle checks. */
|
||||||
|
source?: "optional-group" | "node";
|
||||||
/** Execution status */
|
/** Execution status */
|
||||||
status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending";
|
status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending";
|
||||||
/** Output from the workflow step agent (findings, errors, etc.) */
|
/** 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);
|
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", () => {
|
it("produces 8 items with the correct completed count for 6 impl steps + 2 workflow steps", () => {
|
||||||
const progress = getUnifiedTaskProgress(
|
const progress = getUnifiedTaskProgress(
|
||||||
makeTask({
|
makeTask({
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Task, WorkflowStepResult, WorkflowStepPhase, StepStatus } from "@f
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowSteps 2026-06-25-00:00:
|
FNXC:WorkflowSteps 2026-06-25-00:00:
|
||||||
Graph-native workflow steps (plan U3). Workflow step status now comes entirely from the graph-written
|
Graph-native workflow steps (plan U3). Optional workflow step status now comes from graph-written
|
||||||
`task.workflowStepResults` entries (keyed by node id === enabledWorkflowSteps[i]); the legacy
|
`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`
|
`/api/workflow-steps` DB-row name lookup was dropped, so step names resolve from `result.workflowStepName`
|
||||||
with a fallback to the raw id.
|
with a fallback to the raw id.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Render states (design-lens): the progress model distinguishes
|
|||||||
- `failed` (blocking gate failure — red)
|
- `failed` (blocking gate failure — red)
|
||||||
- `skipped`
|
- `skipped`
|
||||||
Disabled optional steps are simply absent from `enabledWorkflowSteps`, so they never appear in the
|
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";
|
export type UnifiedTaskProgressStatus = StepStatus | "failed" | "advisory_failure" | "running";
|
||||||
@@ -112,6 +112,20 @@ export function getUnifiedTaskProgress(
|
|||||||
phase: result?.phase ?? "pre-merge",
|
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:
|
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 preExecutionWorkflowItems = workflowItems.filter((item) => item.id === "workflow-plan-review");
|
||||||
const remainingWorkflowItems = 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 total = items.length;
|
||||||
const completed = items.filter((item) => isCompleted(item.status)).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)", () => {
|
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 () => {
|
it("(a) enabled group + APPROVE verdict → one entry keyed by the group node id with status 'passed'", async () => {
|
||||||
const recorder = makeRecorder();
|
const recorder = makeRecorder();
|
||||||
const executor = new WorkflowGraphExecutor({
|
const executor = new WorkflowGraphExecutor({
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ export class WorkflowGraphExecutor {
|
|||||||
getLiveSteps: () => this.resolveTaskSteps(task),
|
getLiveSteps: () => this.resolveTaskSteps(task),
|
||||||
context,
|
context,
|
||||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
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),
|
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||||
persistence: this.deps.stepInstancePersistence,
|
persistence: this.deps.stepInstancePersistence,
|
||||||
onReworkReset: this.deps.onReworkReset,
|
onReworkReset: this.deps.onReworkReset,
|
||||||
@@ -557,7 +557,7 @@ export class WorkflowGraphExecutor {
|
|||||||
const loopResult = await runLoop(node, {
|
const loopResult = await runLoop(node, {
|
||||||
context,
|
context,
|
||||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
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),
|
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||||
signal: this.deps.signal,
|
signal: this.deps.signal,
|
||||||
now: this.deps.runLoopNowForTests,
|
now: this.deps.runLoopNowForTests,
|
||||||
@@ -670,6 +670,7 @@ export class WorkflowGraphExecutor {
|
|||||||
workflowStepName: groupName,
|
workflowStepName: groupName,
|
||||||
phase: stepPhase,
|
phase: stepPhase,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
source: "optional-group",
|
||||||
startedAt: stepStartedAt,
|
startedAt: stepStartedAt,
|
||||||
});
|
});
|
||||||
this.deps.logTaskEntry?.(`${logPrefix} Starting workflow step: ${groupName}`);
|
this.deps.logTaskEntry?.(`${logPrefix} Starting workflow step: ${groupName}`);
|
||||||
@@ -677,7 +678,7 @@ export class WorkflowGraphExecutor {
|
|||||||
const groupResult = await runOptionalGroup(node, {
|
const groupResult = await runOptionalGroup(node, {
|
||||||
context,
|
context,
|
||||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
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),
|
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||||
signal: this.deps.signal,
|
signal: this.deps.signal,
|
||||||
});
|
});
|
||||||
@@ -705,6 +706,7 @@ export class WorkflowGraphExecutor {
|
|||||||
workflowStepId: node.id,
|
workflowStepId: node.id,
|
||||||
workflowStepName: groupName,
|
workflowStepName: groupName,
|
||||||
phase: stepPhase,
|
phase: stepPhase,
|
||||||
|
source: "optional-group",
|
||||||
status: stepStatus,
|
status: stepStatus,
|
||||||
...(verdict ? { verdict } : {}),
|
...(verdict ? { verdict } : {}),
|
||||||
...(stepOutput !== undefined ? { output: stepOutput } : {}),
|
...(stepOutput !== undefined ? { output: stepOutput } : {}),
|
||||||
@@ -1127,6 +1129,7 @@ export class WorkflowGraphExecutor {
|
|||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
workflow: WorkflowIr,
|
workflow: WorkflowIr,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
recordProgress = true,
|
||||||
): Promise<WorkflowNodeResult> {
|
): Promise<WorkflowNodeResult> {
|
||||||
const handler = this.handlers[node.kind];
|
const handler = this.handlers[node.kind];
|
||||||
|
|
||||||
@@ -1142,21 +1145,32 @@ export class WorkflowGraphExecutor {
|
|||||||
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||||
try {
|
try {
|
||||||
await this.prepareNodeExecution(node, task);
|
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);
|
const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal);
|
||||||
if (pluginResult) {
|
if (pluginResult) {
|
||||||
const projected = await this.publishTaskProjectionFromResult(task.id, node, pluginResult);
|
const projected = await this.publishTaskProjectionFromResult(task.id, node, pluginResult);
|
||||||
return signal?.aborted || this.isAbortNodeResult(projected)
|
if (signal?.aborted || this.isAbortNodeResult(projected)) {
|
||||||
? this.withEnginePauseAbortContext(node, projected)
|
return this.withEnginePauseAbortContext(node, projected);
|
||||||
: projected;
|
}
|
||||||
|
if (progressRecord) {
|
||||||
|
await this.recordNodeProgressFinish(task.id, node, progressRecord, projected);
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
}
|
}
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
|
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
|
||||||
}
|
}
|
||||||
const result = await handler(node, { task, settings, context, signal });
|
const result = await handler(node, { task, settings, context, signal });
|
||||||
const projected = await this.publishTaskProjectionFromResult(task.id, node, result);
|
const projected = await this.publishTaskProjectionFromResult(task.id, node, result);
|
||||||
return signal?.aborted || this.isAbortNodeResult(projected)
|
if (signal?.aborted || this.isAbortNodeResult(projected)) {
|
||||||
? this.withEnginePauseAbortContext(node, projected)
|
return this.withEnginePauseAbortContext(node, projected);
|
||||||
: projected;
|
}
|
||||||
|
if (progressRecord) {
|
||||||
|
await this.recordNodeProgressFinish(task.id, node, progressRecord, projected);
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -1167,13 +1181,68 @@ export class WorkflowGraphExecutor {
|
|||||||
return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const failureResult: WorkflowNodeResult = {
|
||||||
outcome: "failure",
|
outcome: "failure",
|
||||||
value: "exception",
|
value: "exception",
|
||||||
contextPatch: {
|
contextPatch: {
|
||||||
[`node:${node.id}:error`]: lastError instanceof Error ? lastError.message : String(lastError),
|
[`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> {
|
private async prepareNodeExecution(node: WorkflowIrNode, task: TaskDetail): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user