diff --git a/.changeset/persist-pre-archive-column.md b/.changeset/persist-pre-archive-column.md new file mode 100644 index 0000000000..d4b3da0c4e --- /dev/null +++ b/.changeset/persist-pre-archive-column.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore an archived card to the lane it was archived from, not to Done. +category: fix +dev: `preArchiveColumn` was never captured — it has no `project.tasks` column and lives only in the archive snapshot — so `unarchiveTaskImpl` always fell to its `?? "todo"` literal. On a custom workflow `todo` is undeclared, so the destination resolver took its no-usable-history branch and returned the complete lane. Captures the column into the snapshot on archive and reads the snapshot (not the restored row) on unarchive; both halves are required. diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index 4e1a46ec99..42a80c6f4e 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -44,7 +44,31 @@ export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archi description: task.description, priority: normalizeTaskPriority(task.priority), column: "archived", - preArchiveColumn: task.preArchiveColumn, + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-11:30 (PR #2824's finding, fixed): + CAPTURE THE COLUMN THE CARD WAS IN. This field was only ever COPIED — here, back out of the + entry on restore, and through serialization — and never SET from anywhere, so it was `undefined` + for every archive that has ever happened. `unarchiveTaskImpl` then fell to its `?? "todo"` and + the restore destination was decided by a literal instead of by history. + + On the default board `todo` is a declared column, so restores landed in the queue and looked + right — which is why this survived three separate fixes to `resolveUnarchiveTargetColumnImpl`, + all of which were correcting how it interprets a value that never arrived. On a renamed board + `todo` is declared nowhere, so the resolver took its "no usable history" branch and returned the + COMPLETE lane: a card archived mid-implementation came back marked finished. Proven end to end + in `workflow-unarchive-target-live-e2e.pg.test.ts`. + + `task.column` is the pre-archive column at this point — the entry's own `column` is set to + `"archived"` on the line above, so this is the last place the original is still in hand. The + `??` keeps an already-captured value, so a re-archive of a restored card does not overwrite the + history with an intermediate lane. + + DEFAULT-BOARD BEHAVIOUR CHANGES, deliberately: a card archived from `done` restored to `todo` + under the literal and now restores to `done`. Returning finished work to the queue was the + fallback showing through, not a rule anyone chose — the resolver's own branches say a card + archived from a declared column goes back to it. + */ + preArchiveColumn: task.preArchiveColumn ?? (task.column as ArchivedTaskEntry["preArchiveColumn"]), dependencies: task.dependencies, steps: task.steps, currentStep: task.currentStep, @@ -438,7 +462,25 @@ export async function unarchiveTaskImpl(store: TaskStore, id: string): Promise the board's HOLD lane; unfinished work returns to the queue + archived from any other declared -> that same column; its history is usable as-is + archived from `archived`, or from a -> the board's COMPLETE lane + column the board no longer declares - default: after archive column=archived preArchiveColumn=undefined -> resolver target=todo - renamed: after archive column=archived preArchiveColumn=undefined -> resolver target=shipped - -So the fallback fires for every restore that has ever happened, and the two boards diverge because of -what `"todo"` means to each: - - DEFAULT BOARD `todo` is a declared column, so the resolver returns it. Every restore lands in the - queue. That is right for a card archived mid-implementation and wrong for one - archived from `done` — but it LOOKS right, which is why this survived. - RENAMED BOARD `todo` is declared nowhere, so the resolver takes its "no usable history" branch and - returns the COMPLETE lane. Archive a card mid-implementation, restore it, and it - comes back marked FINISHED. - -That is the operator-visible defect, and it explains the track record: the three earlier fixes were -correcting how the resolver interprets a value that never arrives. - -THE CASES BELOW ARE CHARACTERIZATION. They assert today's wrong-but-real behaviour so the defect is -executable rather than argued, and they are written to flip: the fix is to persist the card's column -when archiving, at which point every renamed expectation becomes the lane the card was actually in. -See the PR body — that fix also changes DEFAULT-board placement for cards archived from `done`, which -is why it is proposed rather than smuggled in here. +`.review` is derived from the `mergeOrchestration` flag, NOT from `human-review` (a distinction that +cost me a wrong expectation here). This fixture's review column declares `human-review` and +`merge-blocker` only, so it is not a `.review` lane to the resolver — it is simply a declared column +with usable history, and a card archived from it restores THERE. That is the correct answer, and the +case below asserts it with the reasoning attached so the next reader does not "fix" it to hold. LANE. `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is reachable, so the merge gate is unaffected. Throwaway per-file database; never port 4040. @@ -91,15 +85,15 @@ pgDescribe("unarchive destination resolves the card's own board", () => { } it("CONTROL — on the DEFAULT board a restore lands in the queue, and looks correct", async () => { - /* Correct for this card by coincidence: the hardcoded `"todo"` fallback happens to be the default - board's hold lane. That coincidence is what has hidden the missing write. */ + /* Unchanged by the fix, and that is the point: the literal happened to be this board's hold lane, + which is why the defect was invisible here for so long. */ const store = h.store(); expect(await archiveThenUnarchive(store, DEFAULT_VOCAB, "wf-default-wip", DEFAULT_VOCAB.wip)) .toBe(DEFAULT_VOCAB.hold); }); - it("CHARACTERIZATION — a RENAMED board's card archived from WIP comes back marked FINISHED", async () => { + it("a RENAMED board's card archived from WIP returns to ITS queue", async () => { /* The card was mid-implementation, so it goes back to the queue — but the queue is `backlog`, not `todo`. A legacy-enum gate anywhere upstream sends it to the complete lane or to an id this board @@ -108,22 +102,23 @@ pgDescribe("unarchive destination resolves the card's own board", () => { */ const store = h.store(); - /* Expected: `hold` — the queue it should return to. Actual: the COMPLETE lane. */ expect(await archiveThenUnarchive(store, RENAMED_VOCAB, "wf-renamed-wip", RENAMED_VOCAB.wip)) - .toBe(RENAMED_VOCAB.complete); + .toBe(RENAMED_VOCAB.hold); }); - it("CHARACTERIZATION — one archived from REVIEW does too", async () => { + it("one archived from a human-review lane returns to that lane", async () => { /* The review lane is grouped with WIP by the resolver — unfinished work returns to the queue. Asserted separately because the two ids are resolved from different traits, so a conversion could fix one role and leave the other. */ const store = h.store(); + /* Its OWN lane, not hold — see the header: this column declares `human-review` but no merge + orchestration, so it is a declared column with usable history rather than a `.review` lane. */ expect(await archiveThenUnarchive(store, RENAMED_VOCAB, "wf-renamed-review", RENAMED_VOCAB.review)) - .toBe(RENAMED_VOCAB.complete); + .toBe(RENAMED_VOCAB.review); }); - it("CHARACTERIZATION — and so does one archived from the HOLD lane it should return to", async () => { + it("one archived from the HOLD lane returns there", async () => { /* The differential that stops the two cases above passing for a trivial reason. If the resolver simply returned the hold lane for everything, all three would be green — this one distinguishes @@ -133,10 +128,10 @@ pgDescribe("unarchive destination resolves the card's own board", () => { const store = h.store(); expect(await archiveThenUnarchive(store, RENAMED_VOCAB, "wf-renamed-hold", RENAMED_VOCAB.hold)) - .toBe(RENAMED_VOCAB.complete); + .toBe(RENAMED_VOCAB.hold); }); - it("a card archived from COMPLETE lands in the complete lane — the only one that is right, and only by accident", async () => { + it("a card archived from COMPLETE stays finished", async () => { /* Finished work keeps its history too, and its id must be the board's own `shipped` — the case a `?? "done"` fallback would silently answer with the legacy id. */ const store = h.store();