Files
fusion/packages/engine/src/__tests__/workflow-graph-entry-contract.test.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

407 lines
21 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
BUILTIN_CODING_WORKFLOW_IR,
BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR,
getBuiltinWorkflow,
parseWorkflowIr,
type WorkflowIr,
} from "@fusion/core";
import { resolveColumnResumeNode, WorkflowGraphExecutor } from "../workflows/workflow-graph-executor.js";
import type { WorkflowRuntimePrimitives } from "../execution/runtime-primitives.js";
import type { TaskDetail, TaskStep } from "@fusion/core";
/*
FNXC:WorkflowGraphEntry 2026-07-26-17:10:
THE GRAPH ENTRY CONTRACT. A run with no durable continuation resumes at the card's OWN column instead
of replaying the pipeline from `start`. Before this, every continuation-less run (self-healing graph
re-entry, a fresh dispatch, an operator drag into a processing column) re-entered at the first node of
the FIRST column and dragged the card backward through columns it had already left — aborting its live
session via `abort-on-exit`, and stranding it in any pre-wip column with no releaser. That backward
drag is the reason planning nodes had to live in the implementation column.
These assert the INVARIANT across every lifecycle position a card can hold, not just the plan-in-place
case that motivated it: behind (resume forward), at, and past each column, on the real built-in IRs.
*/
const codingIr = parseWorkflowIr(getBuiltinWorkflow("builtin:coding")!.ir as never);
describe("workflow graph entry contract — resume at the card's own column", () => {
/*
FNXC:MergedPlanningColumn 2026-07-28-17:40 (U11):
EXPECTATION CHANGED BY THE MERGE, recorded rather than relaxed. This asserted two entry points
because the default workflow had two pre-implementation columns; it now has one, so `triage`
resolves to undefined (a column the IR does not declare cannot be placed relative to any node)
and the single planning column enters at `start`.
The `undefined` half is NOT the whole story and is deliberately not left as the only assertion:
a card still stored in the removed `triage` column is rescued by `run()`'s start-node fallback,
and that rescue is asserted by driving the executor in the stranded-card suite at the bottom of
this file — verified to go red when the fallback is deleted. Asserting undefined here alone
would be compatible with those cards stranding.
*/
it("enters the planning prologue for a card in the merged planning lane", () => {
const entry = resolveColumnResumeNode(codingIr, "todo");
expect(entry?.column).toBe("todo");
expect(entry?.id).toBe("start");
// …and `start` reaches the specification node in one unconditional hop, which is what makes
// entering there equivalent to entering at the specification node itself.
const successors = codingIr.edges.filter(
(edge) => edge.from === entry!.id
&& (edge.condition === undefined || edge.condition === "success")
&& edge.kind !== "rework",
);
expect(successors).toHaveLength(1);
expect(successors[0]!.to).toBe("plan");
});
it("never re-plans a card that already reached implementation", () => {
const resumed = resolveColumnResumeNode(codingIr, "in-progress");
expect(resumed?.id).toBe("parse");
expect(resumed?.column).toBe("in-progress");
// The regression this exists to prevent: resuming at a planning node would move the card
// backward out of the wip column and abort its session.
expect(["plan", "plan-review", "plan-replan"]).not.toContain(resumed?.id);
});
it("re-enters a review-column card at the FIRST review node so no gate is skipped", () => {
const resumed = resolveColumnResumeNode(codingIr, "in-review");
expect(resumed?.column).toBe("in-review");
// Entering at the merge region instead would silently skip Code Review.
expect(resumed?.id).toBe("browser-verification");
});
it("resolves the same way for the other built-in coding IRs", () => {
expect(resolveColumnResumeNode(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR, "in-progress")?.id).toBe("parse");
/*
FNXC:MergedPlanningColumn 2026-07-28-17:10 (U11):
BUILTIN_CODING_WORKFLOW_IR is `builtin:legacy-coding`, NOT the default workflow — the catalog
maps `builtin:coding` to the stepwise-final-review IR. Legacy coding deliberately keeps the
six-column split shape (its purpose is being the old pipeline; R11 commits to legacy shapes
continuing to work), so it still answers `planning` here. The merged-column assertions live
with the DEFAULT lineage below.
*/
expect(resolveColumnResumeNode(BUILTIN_CODING_WORKFLOW_IR, "todo")?.id).toBe("planning");
expect(resolveColumnResumeNode(BUILTIN_CODING_WORKFLOW_IR, "in-progress")?.id).toBe("execute");
});
it("skips forward when the card rests in a column the pipeline has no node for", () => {
const ir = parseWorkflowIr({
version: "v2",
name: "gap-column",
columns: [
{ id: "intake", name: "Intake", traits: [{ trait: "intake" }] },
// No node declares `staging` — a card parked here must resume at the next node forward.
{ id: "staging", name: "Staging", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "work", name: "Work", traits: [{ trait: "wip" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake" },
{ id: "build", kind: "prompt", column: "work" },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "build", condition: "success" },
{ from: "build", to: "end", condition: "success" },
],
} as WorkflowIr);
expect(resolveColumnResumeNode(ir, "staging")?.id).toBe("build");
});
it("never resumes at a remediation node reached only by a failure or rework edge", () => {
// `plan-replan` sits in the planning lane and is reachable only from a plan-review FAILURE,
// so a planning-lane card must still resume at `plan` — remediation is not an entry point.
expect(resolveColumnResumeNode(codingIr, "todo")?.id).not.toBe("plan-replan");
// Same shape in the implementation column, where the code-review remediation node lives.
expect(resolveColumnResumeNode(codingIr, "in-progress")?.id).not.toBe("code-review-remediation");
});
it("falls back to the start node for an unknown column or a v1 IR", () => {
expect(resolveColumnResumeNode(codingIr, "not-a-column")).toBeUndefined();
expect(resolveColumnResumeNode(codingIr, undefined)).toBeUndefined();
expect(resolveColumnResumeNode({ version: "v1", name: "legacy", nodes: [], edges: [] } as never, "todo"))
.toBeUndefined();
});
});
/*
FNXC:WorkflowGraphEntry 2026-07-27-06:10 (PR #2462 review):
The resolver tests above prove the DECISION; this one proves the executor actually asks. A run that
reached `run()` and ignored the resolver — or that reintroduced the backward `columnBoundary` move —
would satisfy every assertion above and still strand the card, which is exactly the regression the
entry contract exists to prevent. Assert on the real traversal, not on the helper.
*/
describe("workflow graph entry contract — the executor honors it", () => {
const promptWithOneStep = "# Task\n\n## Steps\n\n### Step 0: Implement\n- [ ] do it\n";
function silentPrimitives(calls: string[]): WorkflowRuntimePrimitives {
const ok = { outcome: "success" as const };
return {
prepareWorktree: async () => ({ outcome: "success", data: { worktreePath: "/memory/worktree" } }),
readArtifact: async (_c, _t, key) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeArtifact: async (_c, _t, key) => ({ outcome: "success", data: { key } }),
runPlanningSession: async () => {
calls.push("planning-session");
return { outcome: "success", data: { approved: true, artifactKeys: ["PROMPT.md"] } };
},
runCodingSession: async () => ({ outcome: "success", data: { taskDone: true, modifiedFiles: [] } }),
runTaskStep: async () => ({ outcome: "success", baselineSha: "b", checkpointId: "c" }),
resetTaskStep: async () => ({ ok: true }),
runReview: async () => ({ outcome: "success", data: { verdict: "APPROVE" } }),
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
updateSteps: async (_c, target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
return { outcome: "success", data: { count: steps.length } };
},
transitionTask: async () => ok,
requestMerge: async () => ({ outcome: "success", value: "merged", data: { status: "merged" } }),
abortRun: async () => ok,
audit: () => undefined,
} as unknown as WorkflowRuntimePrimitives;
}
it("resumes an in-progress card at `parse` and never re-enters a planning node", async () => {
const calls: string[] = [];
const task = {
id: "FN-ENTRY",
title: "Entry contract",
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: promptWithOneStep,
workflowStepResults: [],
createdAt: "2026-07-27T00:00:00.000Z",
updatedAt: "2026-07-27T00:00:00.000Z",
} as unknown as TaskDetail;
const executor = new WorkflowGraphExecutor({
primitives: silentPrimitives(calls),
parseStepsDeps: {
readArtifact: async (_target, key) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeSteps: async (target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
},
},
} as never);
// No continuation node id — the "replay from start" path the contract governs.
const result = await executor.run(task, { experimentalFeatures: {} } as never, codingIr);
expect(result.visitedNodeIds[0]).toBe("parse");
for (const planningNode of ["start", "plan", "plan-review", "plan-replan"]) {
expect(result.visitedNodeIds, `must not re-enter ${planningNode}`).not.toContain(planningNode);
}
// The planning primitive is the loudest possible proof: a re-planned card would call it.
expect(calls).not.toContain("planning-session");
});
});
/*
FNXC:MergedPlanningColumn 2026-07-28-14:05 (U11, PR #2503 review — greptile + coderabbit):
The entry contract under the MERGED planning column — one column carrying `intake` + `hold`,
which is what U11 leaves behind once `triage` is deleted and `todo` becomes "Planning".
REWRITTEN after review. The first cut of this block did two things wrong, and both are the
difference between proving something and appearing to:
1. It asserted against a hand-written synthetic graph. A toy graph proves the RESOLVER's
arithmetic, not that the operator's board survives — production planning/review topology could
drift away from the shape under test and nothing here would notice. `mergeTodoIntoPlanning`
below is instead the literal U11 edit expressed as a transformation, applied to the REAL
production IR, so these assertions track production topology by construction.
2. It asserted that `resolveColumnResumeNode` returns `undefined` for a card stranded in the
deleted `triage` column, and that a start node exists. Both can hold while cards strand: the
thing that actually rescues such a card is the `?? ir.nodes.find(kind === "start")` fallback in
`run()`, and neither assertion touches it. Deleting that fallback left the old test GREEN. The
stranded case now drives `executor.run()` and asserts the card really traverses — and going red
when the fallback is removed is verified, not assumed.
*/
describe("workflow graph entry contract — merged intake+hold planning column (U11)", () => {
/*
FNXC:MergedPlanningColumn 2026-07-28-17:30 (U11):
This block previously TRANSFORMED the production IR to preview the merged shape before it
landed. It has landed: `builtin:coding` (the stepwise-final-review lineage) now declares one
pre-implementation column. So the transformation is deleted and these assert the real thing.
The transform helper is not kept "just in case" — it would now be a no-op that quietly asserts
nothing, which is worse than no test. `builtin:legacy-coding` still carries the split shape and
is asserted separately above, so the split vocabulary has not lost coverage.
*/
const mergedCodingIr = codingIr;
it("declares ONE pre-implementation column carrying both intake and hold", () => {
// The shape U11 exists to produce, asserted on the real default workflow.
expect(mergedCodingIr.columns.map((column) => column.id)).not.toContain("triage");
expect(mergedCodingIr.nodes.every((node) => node.column !== "triage")).toBe(true);
const planning = mergedCodingIr.columns.find((column) => column.id === "todo")!;
expect(planning.name).toBe("Planning");
const traits = (planning.traits ?? []).map((trait) => (trait as { trait: string }).trait);
expect(traits).toContain("intake");
expect(traits).toContain("hold");
// Exactly one column carries each pre-implementation role — a second intake or hold column
// would reintroduce the two-stage shape under different ids.
const withTrait = (name: string) => mergedCodingIr.columns.filter(
(column) => (column.traits ?? []).some((trait) => (trait as { trait: string }).trait === name),
);
expect(withTrait("intake")).toHaveLength(1);
expect(withTrait("hold")).toHaveLength(1);
expect(withTrait("intake")[0]!.id).toBe(withTrait("hold")[0]!.id);
});
it("enters the specification phase for a card in the merged planning column", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "todo");
expect(resumed?.column).toBe("todo");
// The failure this guards: entering at an implementation node would put an unspecified card
// into implementation with no plan.
expect(resumed?.id).not.toBe("parse");
});
it("reaches the production specification node from the merged entry point in one hop", () => {
/*
The merged shape answers `start` where the split shape answers the planning node, because
`start` becomes the first node in that column once the columns collapse. That is equivalent
ONLY because `start` reaches the specification node by a single unconditional success edge.
Asserted against the PRODUCTION graph, so inserting a node between them fails here rather than
silently admitting an unspecified card into implementation.
*/
const entry = resolveColumnResumeNode(mergedCodingIr, "todo")!;
const splitAnswer = resolveColumnResumeNode(codingIr, "todo")!;
if (entry.id === splitAnswer.id) return; // no collapse happened; nothing to bridge.
const successors = mergedCodingIr.edges.filter(
(edge) => edge.from === entry.id && (edge.condition === undefined || edge.condition === "success") && edge.kind !== "rework",
);
expect(successors).toHaveLength(1);
expect(successors[0]!.to).toBe(splitAnswer.id);
});
it("NEVER re-plans a card past the merged column — the drag that aborts a live session", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-progress");
expect(resumed?.id).toBe("parse");
expect(resumed?.column).toBe("in-progress");
expect(["start", "plan", "planning", "plan-review", "plan-replan"]).not.toContain(resumed?.id);
});
it("re-enters a review-column card at the production first review node, not the merge region", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-review");
/*
ABSOLUTE, not merely equal-to-production. A differential assertion alone is tautological here:
if the production review lane were reordered, both sides of the comparison would move together
and the test would stay green while the merged board silently skipped a gate. The literal is
the same one the production block above pins, so a real topology change fails in both places.
*/
expect(resumed?.id).toBe("browser-verification");
expect(resumed?.column).toBe("in-review");
// …and it still agrees with production, which is the property the merge must preserve.
expect(resumed?.id).toBe(resolveColumnResumeNode(codingIr, "in-review")?.id);
});
it("produces the same answer as the production IR at every position past planning", () => {
// Absolutes first, so production drift cannot move both sides of the differential in step.
expect(resolveColumnResumeNode(mergedCodingIr, "in-progress")?.id).toBe("parse");
expect(resolveColumnResumeNode(mergedCodingIr, "in-review")?.id).toBe("browser-verification");
for (const column of ["in-progress", "in-review", "done"]) {
expect(resolveColumnResumeNode(mergedCodingIr, column)?.id)
.toBe(resolveColumnResumeNode(codingIr, column)?.id);
}
});
});
/*
FNXC:MergedPlanningColumn 2026-07-28-14:20 (U11, PR #2503 review — coderabbit):
The stranded-card case, driven through `executor.run()` rather than through the resolver.
Rows persisted in `triage` outlive the column U11 deletes. `resolveColumnResumeNode` returns
undefined for a column the IR does not declare, and the ONLY thing that then rescues the card is
`run()`'s `?? ir.nodes.find(kind === "start")` fallback. Asserting "the resolver returns undefined"
and "a start node exists" is compatible with the card stranding, which is why this drives the real
traversal and asserts the card moves.
Verified red: deleting the `?? ir.nodes.find(...)` fallback makes `run()` throw
`WorkflowIrError: Workflow IR missing start node` and this test fails.
*/
describe("workflow graph entry contract — a card stranded in a deleted column still runs (U11)", () => {
const promptWithOneStep = "# Task\n\n## Steps\n\n### Step 0: Implement\n- [ ] do it\n";
function silentPrimitives(): WorkflowRuntimePrimitives {
const ok = { outcome: "success" as const };
return {
prepareWorktree: async () => ({ outcome: "success", data: { worktreePath: "/memory/worktree" } }),
readArtifact: async (_c: unknown, _t: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeArtifact: async (_c: unknown, _t: unknown, key: string) => ({ outcome: "success", data: { key } }),
runPlanningSession: async () => ({ outcome: "success", data: { approved: true, artifactKeys: ["PROMPT.md"] } }),
runCodingSession: async () => ({ outcome: "success", data: { taskDone: true, modifiedFiles: [] } }),
runTaskStep: async () => ({ outcome: "success", baselineSha: "b", checkpointId: "c" }),
resetTaskStep: async () => ({ ok: true }),
runReview: async () => ({ outcome: "success", data: { verdict: "APPROVE" } }),
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
updateSteps: async (_c: unknown, target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
return { outcome: "success", data: { count: steps.length } };
},
transitionTask: async () => ok,
requestMerge: async () => ({ outcome: "success", value: "merged", data: { status: "merged" } }),
abortRun: async () => ok,
audit: () => undefined,
} as unknown as WorkflowRuntimePrimitives;
}
/** Production is already merged, so this is the real default IR — no transform needed. */
function mergedIrWithoutTriage(): WorkflowIr {
return codingIr;
}
it("runs a card still stored in the DELETED triage column instead of stranding it", async () => {
const task = {
id: "FN-STRANDED",
title: "Left behind in a deleted column",
description: "",
// The migration case: the row outlived the column its workflow declared.
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: promptWithOneStep,
workflowStepResults: [],
createdAt: "2026-07-28T00:00:00.000Z",
updatedAt: "2026-07-28T00:00:00.000Z",
} as unknown as TaskDetail;
const executor = new WorkflowGraphExecutor({
primitives: silentPrimitives(),
parseStepsDeps: {
readArtifact: async (_target: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeSteps: async (target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
},
},
} as never);
const merged = mergedIrWithoutTriage();
expect(merged.columns.map((column) => column.id)).not.toContain("triage");
// No continuation node id — the "replay from start" path the fallback governs.
const result = await executor.run(task, { experimentalFeatures: {} } as never, merged);
// The card must actually traverse. An empty trace IS the stranding this guards.
expect(result.visitedNodeIds.length).toBeGreaterThan(0);
// …and it re-enters at the top of the pipeline, which is the only safe answer for a column
// the workflow no longer declares: it cannot be placed relative to any node.
expect(result.visitedNodeIds[0]).toBe(merged.nodes.find((node) => node.kind === "start")?.id);
});
});