From 6f936f2de7f7932ffd57383b0b29369f60909c37 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 00:52:50 -0700 Subject: [PATCH] fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The node-override guard never fired on a renamed board `fn_task_update` called the guard with no options: ```ts const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null); ``` so `wipColumns` fell back to its documented default of `{"in-progress"}`. On a board whose WIP lane is named anything else, `wipColumns.has(task.column)` is false, the mid-flight check passes, and **an operator can change the node override on a running task** — precisely what that guard exists to refuse, in its own words: > "Is this task executing right now?" — keyed on the literal, a renamed board let an operator change the node override MID-FLIGHT on a running task, which is exactly what this guard exists to refuse. That note is attached to the `wipColumns` option added for this purpose. The CLI simply never passed it. ## Two assumptions in the guard's own docs that did not hold ``` Both callers supply them. An omitted set keeps the legacy id, which is what a caller without cheap IR access (a CLI tool, a route with only a task row) still gets. ``` 1. **"Both callers"** — this is a *third* one, and it was in `check-lane-wiring`'s known-unwired baseline the whole time. 2. **"a CLI tool … without cheap IR access"** — this handler is async and has already awaited `store.getTask`, so one more resolve costs exactly what `resolveTaskLifecycleColumns` already costs elsewhere **in this same file** (the linked-lineage label at ~1239). The assumption was reasonable in general and wrong here. Passed present-but-conditionally-valued rather than as a conditional argument: an omitted set still keeps the documented legacy default, and only that shape is visible to `lane-wiring-census`, which matches an object-literal argument and cannot see a ternary. ## Coverage — stated rather than implied **There is no new unit test.** The regression guard is the ratchet itself, and it is a real revert-proof: with the wiring removed, ``` [check-lane-wiring] call sites not passing a resolved lane argument INCREASED: packages/cli/src/extension.ts: 1 unwired now, baseline allows 0 ``` Verified by actually reverting it, not by assuming. Baseline re-recorded 19 → 18 in the same commit, so the allowance cannot be regrown into. A behavioural test would need a custom workflow definition persisted *and* selected inside the integration harness to get a card resting in a renamed WIP lane. That is worth doing and I would take it as follow-up harness work — but it is not part of this fix, and I would rather name the gap than let "85 passed" imply coverage I did not write. ## Verification (measured) - **85 passed** across `extension.test.ts`, `extension-experiment-finalize.test.ts`, `task-list-board-columns.test.ts` - `tsc --noEmit`, `eslint` — clean - `check-lane-wiring` (18, none added), `lifecycle-column-census --strict`, `check-inert-flag-seams`, `check-fnxc-future-dates`, `check:changesets` — green Changeset included (`patch`): `packages/cli` is the published `@runfusion/fusion` and this changes guard behaviour operators rely on. --- .changeset/cli-node-override-wip-lane.md | 7 +++++++ packages/cli/src/extension.ts | 25 +++++++++++++++++++++++- scripts/lib/lane-wiring-baseline.json | 1 - 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-node-override-wip-lane.md diff --git a/.changeset/cli-node-override-wip-lane.md b/.changeset/cli-node-override-wip-lane.md new file mode 100644 index 0000000000..872bd228e7 --- /dev/null +++ b/.changeset/cli-node-override-wip-lane.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the node-override guard not blocking mid-flight changes on boards with a renamed WIP lane. +category: fix +dev: `fn_task_update` called `validateNodeOverrideChange` without options, so `wipColumns` fell back to the literal `{"in-progress"}` and the running-task check never fired on a renamed board. It now resolves the task's own WIP and COMPLETE lanes via `resolveTaskLifecycleColumns`. diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 41f039357b..0d30428da0 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1819,7 +1819,30 @@ export default function kbExtension(pi: ExtensionAPI) { store.updateTask all exhibit identical behavior. */ const normalizedNodeId = normalizeNullableStringInput(params.nodeId); - const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-12:20: + Supply this task's resolved WIP and COMPLETE lanes — without them the guard does not fire. + + `validateNodeOverrideChange` defaults `wipColumns` to `{"in-progress"}`, so on a board whose + WIP lane is named anything else `wipColumns.has(task.column)` is false and the mid-flight + check passes. An operator could then change the node override on a RUNNING task, which is + precisely what that guard exists to refuse (see its own note in node-override-guard.ts). + + The guard's options doc says "Both callers supply them" and assumes a CLI tool has no cheap + IR access. Neither held here: this is a third caller, and it is an async handler that has + already awaited `store.getTask`, so one more resolve is the same cost `resolveTaskLifecycleColumns` + is already paid for elsewhere in this file (the linked-lineage label at ~1239). + + Passed as present-but-conditionally-valued rather than a conditional argument: an omitted + set keeps the documented legacy default, and only this shape is visible to + scripts/lib/lane-wiring-census.mjs, which matches an object-literal argument and cannot see a + ternary. This site was a known-unwired entry in that gate's baseline. + */ + const nodeOverrideLifecycle = await resolveTaskLifecycleColumns(store, task.id); + const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null, { + wipColumns: nodeOverrideLifecycle?.wip ? new Set([nodeOverrideLifecycle.wip]) : undefined, + completeColumns: nodeOverrideLifecycle?.complete ? new Set([nodeOverrideLifecycle.complete]) : undefined, + }); if (!validation.allowed) { return { content: [{ type: "text", text: validation.message ?? "Node override change blocked" }], diff --git a/scripts/lib/lane-wiring-baseline.json b/scripts/lib/lane-wiring-baseline.json index 587e0328aa..3a54486ac3 100644 --- a/scripts/lib/lane-wiring-baseline.json +++ b/scripts/lib/lane-wiring-baseline.json @@ -15,7 +15,6 @@ "packages/dashboard/app/hooks/useBlockerFanout.ts": 1, "packages/cli/src/commands/dashboard-tui/app.tsx": 1, "packages/cli/src/commands/dashboard-tui/bucket-mapping.ts": 1, - "packages/cli/src/extension.ts": 1, "plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx": 1, "plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts": 1 }