From 35b0df1838a62916316722084db67986a54ff157 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 28 Jul 2026 17:35:14 -0700 Subject: [PATCH] U11 PR2: entry contract under the merged column + a real intake-column bug the audit surfaced (#2503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second small PR for **U11**. Two commits: a tests-only entry-contract pin, then a **real present-day bug fix** the audit surfaced. ## The audit you asked for, finished — no design fork You named four surfaces as the remaining risk. All four can take a combined `intake` + `hold` column. One needed a code change; here it is. | Surface | Verdict | Evidence | |---|---|---| | `isUnplannedForExecution` | Safe | PR1 (#2495) — passed unmodified; a mutation now fails exactly the merged-column test | | Capacity hold / release | Safe | PR1 — `hold-release.ts:260` already accepts intake **or** hold | | `start`'s column / entry contract | Safe | commit 1 — all 6 assertions passed unmodified | | `createTask` intake wiring | **Broken today** | commit 2 — fixed, revert-proven | | *(also found)* triage auto-discovery | Needs conversion | `triage.ts:1382` — deferred to PR3, see below | ## Commit 1 — entry contract under the merged column (tests only) All 6 new assertions passed on the first run. **Regression floor, not evidence of a fix** — I could not make them fail and am not claiming otherwise. They pin one real behavioral **difference** rather than asserting sameness everywhere: the merged shape answers `start` where the split shape answers `plan`, 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, so if a node is ever inserted between them this fails instead of silently admitting an unspecified card into implementation. Also pinned: past planning both shapes agree exactly; a card past the merged column still never resumes at a planning node (the backward drag that fires `abort-on-exit`); and a row persisted in the **deleted** `triage` column resolves to `undefined`, safe only while the executor's start-node fallback exists. ## Commit 2 — a real bug, found by the audit The intake column was resolved **only** as a by-product of materializing workflow steps. A create supplying `enabledWorkflowSteps` without an explicit `workflowId` takes **neither** materialization branch, so `resolvedEntryColumn` stays `undefined` and `column:` falls through to the hard-coded `|| "triage"`. Today, on Coding (Ideas), that lands the card in `triage` — **a column that workflow does not declare.** Created straight into a phantom lane. Measured: the new test fails `expected 'triage' to be 'ideas'` against unmodified sources. **Why it blocks U11.** Once `triage` leaves the coding IRs this stops being an Ideas edge case and becomes the default workflow's behavior for every create down this path: the card lands in an undeclared column **and** — because `isIntakeColumn` keys on the same `"triage"` literal — gets `generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only when its `PROMPT.md` reads as a seed, so a placeholder spec is classified "already planned" and never planned. The card sits in Planning forever with no log line in any lane — **FN-8587's exact failure mode, promoted from one edge case to every new card.** The fix resolves the intake column **side-effect-free** (read the IR, ask which column carries `intake`). It deliberately does *not* call `materializeDefaultWorkflowSteps`, which would persist step rows the caller explicitly opted out of by supplying its own toggles. Unresolvable workflow returns `undefined` and each call site keeps its legacy fallback, so no path loses behavior when the IR cannot be read. Applied to both create paths. Branch ordering preserved in both — the explicit empty-toggle case (`length === 0` hydrating back as `[]`) still runs, now nested rather than sequential. **Revert check:** with `task-creation.ts` reverted, *"lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied"* fails `expected 'triage' to be 'ideas'`. The companion bootstrap-`PROMPT.md` assertion passes either way today — it is correct **by accident of the `"triage"` literal** — and is kept precisely because that accident disappears with U11. ## Verification 37 tests green across the three intake/create suites; 119 across the entry-contract, merged-column and lifecycle suites; `pnpm test:gate` green (307 + 10 + 71); lint and core typecheck clean. Changeset added. ## Deferred to PR3, with the line numbers `discoverReadyPlanningTasks` has two hardcoded branches: ```ts (t) => t.column === "triage" && isTaskStillInPlanningStage(t) // triage.ts:1382 (t) => t.column === "todo" && !this.processing.has(t.id) … // triage.ts:1389 ``` Delete `triage` and branch 1 matches nothing for coding cards; branch 2 then does all the work and is **narrower** (it admits only `needs-replan` or bootstrap-stub cards). Commit 2 is what makes branch 2 sufficient — every new card now gets a real bootstrap seed. They cannot double-fire: a card is in `todo` xor `triage`. Two adjacent sites are already merged-shape-ready: `triage.ts:3899` skips the redundant same-column move for a plan-in-place card, and `triage.ts:753`'s stale-status sweep already scans both columns. Then the ~10-line IR change, then the migration proof. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/u11-intake-column-resolution.md | 7 + .../store-create-intake-column.test.ts | 35 +++ packages/core/src/task-store/task-creation.ts | 61 ++++- .../workflow-graph-entry-contract.test.ts | 219 ++++++++++++++++++ 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 .changeset/u11-intake-column-resolution.md diff --git a/.changeset/u11-intake-column-resolution.md b/.changeset/u11-intake-column-resolution.md new file mode 100644 index 0000000000..80896ff927 --- /dev/null +++ b/.changeset/u11-intake-column-resolution.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Tasks created with custom workflow-step toggles now land in their workflow's own intake column. +category: fix +dev: `resolveDefaultWorkflowIntakeColumn` resolves the intake column side-effect-free (IR + `intake` trait) when a create supplies `enabledWorkflowSteps` without an explicit `workflowId`, so neither `materializeWorkflowSteps` branch runs. Previously `resolvedEntryColumn` stayed undefined and the card fell through to the hard-coded `|| "triage"`. diff --git a/packages/core/src/__tests__/store-create-intake-column.test.ts b/packages/core/src/__tests__/store-create-intake-column.test.ts index 220a8f8588..816d3e190b 100644 --- a/packages/core/src/__tests__/store-create-intake-column.test.ts +++ b/packages/core/src/__tests__/store-create-intake-column.test.ts @@ -44,6 +44,41 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => { expect(task.column).toBe("ideas"); }); + /* + FNXC:MergedPlanningColumn 2026-07-28-12:40 (U11 precondition): + The intake column is resolved ONLY inside the two `materializeWorkflowSteps` branches. A create + that supplies `enabledWorkflowSteps` without an explicit `workflowId` takes NEITHER branch, so + `resolvedEntryColumn` stays undefined and `column:` falls through to the hard-coded `|| "triage"`. + + Today that lands a Coding (Ideas) card in `triage` — a column that workflow does not declare — + so the card is created straight into a phantom lane. U11 makes this the DEFAULT workflow's + problem too: once `triage` is deleted, every create down this path lands in an undeclared column + and, because `isIntakeColumn` keys on the same literal, also gets `generateSpecifiedPrompt` + instead of the bootstrap seed. Triage's discovery admits a card only when its PROMPT.md reads as + a seed, so the card would sit in Planning forever with no log line in any lane — FN-8587's exact + failure mode, for every new card rather than one edge case. + */ + it("lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied", async () => { + const store = h.store(); + await store.setDefaultWorkflowId("builtin:coding-ideas"); + const task = await store.createTask({ + description: "ideas task created with explicit optional-group toggles", + enabledWorkflowSteps: [], + }); + expect(task.column).toBe("ideas"); + }); + + it("writes a bootstrap PROMPT.md for that same create (so triage can still discover it)", async () => { + const store = h.store(); + await store.setDefaultWorkflowId("builtin:coding-ideas"); + const task = await store.createTask({ + description: "ideas task created with explicit optional-group toggles", + enabledWorkflowSteps: [], + }); + const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8"); + expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description)); + }); + it("lands a Coding (Ideas) task in ideas when it is the project default workflow", async () => { const store = h.store(); await store.setDefaultWorkflowId("builtin:coding-ideas"); diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 73ecce0b4b..2b5a9f82b2 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -24,6 +24,8 @@ import {getErrorMessage} from "../error-message.js"; import {generateTaskLineageId} from "../task-lineage.js"; import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js"; import {buildBootstrapPrompt} from "../mesh-task-replication.js"; +import {resolveWorkflowIrById} from "../workflow-ir-resolver.js"; +import {columnsWithFlag} from "../workflow-lifecycle-traits.js"; import {validateFileScopeInPromptContent} from "../task-store/file-scope.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js"; @@ -59,6 +61,36 @@ function ensureSqliteProposalClaimUniqueness(store: TaskStore): void { ); } + +/* +FNXC:MergedPlanningColumn 2026-07-28-12:55 (U11 precondition): +The intake column used to be resolved ONLY as a by-product of materializing workflow steps, so a +create that supplied `enabledWorkflowSteps` without an explicit `workflowId` took neither +materialization branch and fell through to the hard-coded `|| "triage"`. On Coding (Ideas) that +lands the card in a column the workflow does not declare — a phantom lane, today. + +U11 makes it the DEFAULT workflow's problem: once `triage` is deleted, every create down this path +lands in an undeclared column AND (because `isIntakeColumn` keys on the same literal) gets +`generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only +when its PROMPT.md reads as a seed, so the card would sit in Planning forever with no log line in +any lane — FN-8587's failure mode, for every new card. + +Resolution is deliberately SIDE-EFFECT-FREE: it reads the default workflow's IR and asks which +column carries `intake`. It must not call `materializeDefaultWorkflowSteps`, which persists step +rows the caller explicitly opted out of by supplying its own `enabledWorkflowSteps`. +Unresolvable workflow returns undefined and the caller keeps its existing legacy fallback. +*/ +async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise { + try { + const workflowId = await store.getDefaultWorkflowId(); + if (!workflowId) return undefined; + const ir = await resolveWorkflowIrById(store, workflowId); + return columnsWithFlag(ir, "intake")[0]; + } catch { + return undefined; + } +} + export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise { // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). input = applyReviewLevelPreset(input); @@ -194,10 +226,17 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI }); } } - } else if (input.enabledWorkflowSteps.length === 0) { - // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty - // optional-step selection must hydrate back as [], not undefined. - resolvedWorkflowSteps = []; + } else { + // Caller supplied its own optional-step toggles, so no materialization branch ran and + // `resolvedEntryColumn` was left undefined — the gap that dropped the card into the + // hard-coded `|| "triage"`. The toggles are the caller's; the INTAKE COLUMN is still the + // project default workflow's, so resolve it without materializing steps. + resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store); + if (input.enabledWorkflowSteps.length === 0) { + // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty + // optional-step selection must hydrate back as [], not undefined. + resolvedWorkflowSteps = []; + } } // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:20: @@ -671,10 +710,16 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task }); } } - } else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) { - // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty - // optional-step selection must hydrate back as [], not undefined. - resolvedWorkflowSteps = []; + } else if (input.enabledWorkflowSteps !== undefined) { + // Mirror of the backend path: the caller owns the optional-step toggles, so no + // materialization branch ran and `resolvedEntryColumn` was left undefined. The INTAKE + // COLUMN is still the project default workflow's — resolve it without materializing steps. + resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store); + if (input.enabledWorkflowSteps.length === 0) { + // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty + // optional-step selection must hydrate back as [], not undefined. + resolvedWorkflowSteps = []; + } } let createdTask: Task; diff --git a/packages/engine/src/__tests__/workflow-graph-entry-contract.test.ts b/packages/engine/src/__tests__/workflow-graph-entry-contract.test.ts index 5dd9d56de3..d30e14796b 100644 --- a/packages/engine/src/__tests__/workflow-graph-entry-contract.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-entry-contract.test.ts @@ -170,3 +170,222 @@ describe("workflow graph entry contract — the executor honors 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)", () => { + /** + * The U11 IR edit, as a transformation of a real workflow: `triage`'s traits merge into `todo`, + * `todo` becomes "Planning", `triage` is deleted, and every node that named `triage` is repointed. + * Applying this to the production IR is what makes the assertions below track production. + */ + function mergeTodoIntoPlanning(source: WorkflowIr): WorkflowIr { + const ir = structuredClone(source) as WorkflowIr & { + columns: Array<{ id: string; name?: string; traits?: unknown[] }>; + nodes: Array<{ id: string; column?: string }>; + }; + const triage = ir.columns.find((column) => column.id === "triage"); + const todo = ir.columns.find((column) => column.id === "todo"); + if (!triage || !todo) throw new Error("source IR is not the split-column shape this merge transforms"); + + todo.name = "Planning"; + // intake first, then the existing hold/reset-on-entry — the union U11 declares. + todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])]; + ir.columns = ir.columns.filter((column) => column.id !== "triage"); + for (const node of ir.nodes) { + if (node.column === "triage") node.column = "todo"; + } + return ir as WorkflowIr; + } + + const mergedCodingIr = mergeTodoIntoPlanning(codingIr); + + it("is a faithful merge of the production IR (guards the transformation itself)", () => { + // If this drifts, every assertion below is measuring the wrong thing. + 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")!; + const traits = (planning.traits ?? []).map((trait) => (trait as { trait: string }).trait); + expect(traits).toContain("intake"); + expect(traits).toContain("hold"); + // Node COUNT is unchanged: this is a column merge, not a graph edit. + expect(mergedCodingIr.nodes.length).toBe(codingIr.nodes.length); + }); + + 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; + } + + function mergedIrWithoutTriage(): WorkflowIr { + const ir = structuredClone(codingIr) as WorkflowIr & { + columns: Array<{ id: string; name?: string; traits?: unknown[] }>; + nodes: Array<{ id: string; column?: string }>; + }; + const triage = ir.columns.find((column) => column.id === "triage")!; + const todo = ir.columns.find((column) => column.id === "todo")!; + todo.name = "Planning"; + todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])]; + ir.columns = ir.columns.filter((column) => column.id !== "triage"); + for (const node of ir.nodes) if (node.column === "triage") node.column = "todo"; + return ir as WorkflowIr; + } + + 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); + }); +});