fix(dashboard): preserve recommendation recovery across archive lanes (#3360)

## Summary
- align dependency-link tests with prompt-only Definition refreshes
- classify archived recommendation tasks through shared workflow-trait
lanes, including legacy and v1 compatibility
- restore deterministic recommendation tasks directly to their workflow
intake lane without invoking cold-storage unarchive

## Test plan
- `FUSION_DASHBOARD_DEEP=1 corepack pnpm --filter @fusion/dashboard exec
vitest run src/routes/__tests__/task-recommendation-routes.test.ts
src/__tests__/task-lifecycle-lanes.test.ts
app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
--silent=passed-only --reporter=dot`
- `corepack pnpm --filter @fusion/dashboard typecheck`
- `corepack pnpm --filter @fusion/dashboard build`
- `corepack pnpm exec eslint
packages/dashboard/src/routes/register-task-workflow-routes.ts
packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts
packages/dashboard/src/task-lifecycle-lanes.ts
packages/dashboard/src/__tests__/task-lifecycle-lanes.test.ts
packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`
- `corepack pnpm check:lifecycle-columns`
- `corepack pnpm check:changesets --strict`
This commit is contained in:
Phil Larson
2026-08-09 16:22:32 -07:00
committed by GitHub
parent 9ce698699a
commit a39a5116c2
6 changed files with 387 additions and 52 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep recommendation-created tasks recoverable across custom and legacy archive lanes.
category: fix
dev: Treats undeclared legacy archive IDs as tombstones and re-homes active rows without cold-storage restore.

View File

@@ -2256,7 +2256,18 @@ describe("TaskDetailModal", () => {
const mockDetail = makeTask({ id: "FN-001", description: "Dep 1" }); const mockDetail = makeTask({ id: "FN-001", description: "Dep 1" });
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
mockFetch.mockResolvedValue(mockDetail); /*
FNXC:TaskDetailDependencies 2026-08-08-12:32:
A TaskDetail prop refreshes Definition through fetchTaskPrompt. The full-detail client is
reserved for the clicked dependency, so fail this fixture on any other request.
*/
mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (id !== "FN-001" || requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency detail request: ${id}`);
}
return mockDetail;
});
const { baseElement: container } = render( const { baseElement: container } = render(
<TaskDetailModal <TaskDetailModal
initialTab="definition" initialTab="definition"
@@ -2348,7 +2359,18 @@ describe("TaskDetailModal", () => {
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
const addToast = vi.fn(); const addToast = vi.fn();
mockFetch.mockRejectedValueOnce(new Error("Task not found")); /*
FNXC:TaskDetailDependencies 2026-08-08-12:32:
Definition refresh uses fetchTaskPrompt. Reject the expected dependency request and fail this
fixture distinctly if another full-detail request appears.
*/
mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (id !== "FN-001" || requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency detail request: ${id}`);
}
throw new Error("Task not found");
});
const { baseElement: container } = render( const { baseElement: container } = render(
<TaskDetailModal <TaskDetailModal
initialTab="definition" initialTab="definition"
@@ -2382,7 +2404,15 @@ describe("TaskDetailModal", () => {
const blockingDetail = makeTask({ id: "FN-100", description: "Blocking dependent" }); const blockingDetail = makeTask({ id: "FN-100", description: "Blocking dependent" });
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
mockFetch.mockImplementation(async (id) => id === "FN-001" ? upstreamDetail : blockingDetail); mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency project: ${requestedProjectId ?? "<none>"}`);
}
if (id === "FN-001") return upstreamDetail;
if (id === "FN-100") return blockingDetail;
throw new Error(`Unexpected dependency detail request: ${id}`);
});
const { baseElement: container } = render( const { baseElement: container } = render(
<TaskDetailModal <TaskDetailModal
initialTab="definition" initialTab="definition"

View File

@@ -50,10 +50,10 @@ const V1_UPGRADED_IR = {
}; };
describe("landedColumnsForTask", () => { describe("landedColumnsForTask", () => {
it("returns the renamed complete AND archived lanes, and not the legacy ids", async () => { it("returns the renamed complete and archived lanes plus the undeclared legacy archive tombstone", async () => {
const landed = await landedColumnsForTask(storeWith(RENAMED_IR), "FN-1"); const landed = await landedColumnsForTask(storeWith(RENAMED_IR), "FN-1");
expect([...landed].sort()).toEqual(["attic", "shipped"]); expect([...landed].sort()).toEqual(["archived", "attic", "shipped"]);
/* /*
The archived half is asserted explicitly: the two roles resolve independently and have failed The archived half is asserted explicitly: the two roles resolve independently and have failed
independently before, so a fixture that only proved `complete` would miss half the guard. independently before, so a fixture that only proved `complete` would miss half the guard.
@@ -168,6 +168,13 @@ describe("the lane resolvers themselves, not just their callers", () => {
["preWipColumnsForTask", preWipColumnsForTask, "backlog", "todo"], ["preWipColumnsForTask", preWipColumnsForTask, "backlog", "todo"],
]; ];
it("archivedColumnsForTask keeps an undeclared legacy archive tombstone beside traited lanes", async () => {
expect([...(await archivedColumnsForTask(storeFor(RENAMED_IR), "FN-1"))].sort()).toEqual([
"archived",
"attic",
]);
});
for (const [name, resolve, renamed, legacy] of cases) { for (const [name, resolve, renamed, legacy] of cases) {
it(`${name} returns the traited lane and EXCLUDES the untraited legacy name`, async () => { it(`${name} returns the traited lane and EXCLUDES the untraited legacy name`, async () => {
const lanes = await resolve(storeFor(TRAITED_WITH_LEGACY_NAMES), "FN-1"); const lanes = await resolve(storeFor(TRAITED_WITH_LEGACY_NAMES), "FN-1");

View File

@@ -135,6 +135,78 @@ function parent(overrides: Partial<Task> = {}): Task {
}); });
} }
/*
FNXC:TaskRecommendations 2026-08-08-12:49:
Custom-workflow fixtures distinguish the traited `boxed` archived lane from an explicitly declared,
untraited `archived` live lane while retaining undeclared legacy tombstone coverage.
*/
function installCustomRecommendationWorkflow(
store: Partial<TaskStore>,
taskIds: readonly string[],
options: { declareLegacyArchivedAsLive?: boolean } = {},
): void {
Object.assign(store, {
getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "recommendation-workflow",
name: "Recommendation workflow",
kind: "workflow",
ir: {
version: "v2",
id: "recommendation-workflow",
name: "Recommendation workflow",
nodes: [{ id: "start", kind: "start", column: "backlog" }],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
...(options.declareLegacyArchivedAsLive
? [{ id: "archived", name: "Live archived", traits: [] }]
: []),
{ id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] },
],
},
})),
});
}
/*
FNXC:TaskRecommendations 2026-08-09-06:06:
Real v1 workflow fixtures must pass through the production read-path upgrade so synthesized default
columns keep legacy `archived` tombstone semantics instead of looking like an explicitly live lane.
*/
function installLegacyV1RecommendationWorkflow(
store: Partial<TaskStore>,
taskIds: readonly string[],
): void {
Object.assign(store, {
getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "legacy-recommendation-workflow",
name: "Legacy recommendation workflow",
kind: "workflow",
ir: JSON.stringify({
version: "v1",
name: "legacy-recommendation-workflow",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
}),
})),
});
}
describe("recommendation task creation route", () => { describe("recommendation task creation route", () => {
beforeEach(() => locks?.clear()); beforeEach(() => locks?.clear());
afterEach(() => { locks?.clear(); vi.restoreAllMocks(); }); afterEach(() => { locks?.clear(); vi.restoreAllMocks(); });
@@ -318,7 +390,8 @@ describe("recommendation task creation route", () => {
expect(recovered.status).toBe(200); expect(recovered.status).toBe(200);
expect(store.createTask).toHaveBeenCalledTimes(1); expect(store.createTask).toHaveBeenCalledTimes(1);
expect(store.unarchiveTask).toHaveBeenCalledWith("FN-102"); expect(store.unarchiveTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "todo", { recoveryRehome: true });
expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-102"); expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-102");
}); });
@@ -328,29 +401,7 @@ describe("recommendation task creation route", () => {
column: "todo", createdAt: "2026-01-01T00:00:00.000Z", column: "todo", createdAt: "2026-01-01T00:00:00.000Z",
}); });
const { app, store, tasks } = buildApp([parent(), canonical]); const { app, store, tasks } = buildApp([parent(), canonical]);
Object.assign(store, { installCustomRecommendationWorkflow(store, ["FN-102"]);
getTaskWorkflowSelection: vi.fn((id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "recommendation-workflow",
name: "Recommendation workflow",
kind: "workflow",
ir: {
version: "v2",
id: "recommendation-workflow",
name: "Recommendation workflow",
nodes: [{ id: "start", kind: "start", column: "backlog" }],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
{ id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] },
],
},
})),
});
(store.findRecentTasksByContentFingerprint as ReturnType<typeof vi.fn>) (store.findRecentTasksByContentFingerprint as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([]) .mockResolvedValueOnce([])
.mockResolvedValueOnce([canonical]); .mockResolvedValueOnce([canonical]);
@@ -363,7 +414,8 @@ describe("recommendation task creation route", () => {
const recovered = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined); const recovered = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined);
expect(recovered.status).toBe(200); expect(recovered.status).toBe(200);
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog"); expect(store.unarchiveTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog", { recoveryRehome: true });
expect(tasks.find((item) => item.id === "FN-102")?.column).toBe("backlog"); expect(tasks.find((item) => item.id === "FN-102")?.column).toBe("backlog");
}); });
@@ -454,6 +506,232 @@ describe("recommendation task creation route", () => {
expect(archived.store.createTask).not.toHaveBeenCalled(); expect(archived.store.createTask).not.toHaveBeenCalled();
}); });
it.each(["boxed", "archived"] as const)(
"rejects a linked child in custom workflow archived state %s",
async (archivedColumn) => {
const archivedChild = task({ id: "FN-9", description: "Archived child", column: archivedColumn as Column });
const custom = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
archivedChild,
]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(custom.store.createTask).not.toHaveBeenCalled();
},
);
it("keeps a linked child live in an explicitly declared untraited archived column", async () => {
const linkedChild = task({ id: "FN-9", description: "Live child", column: "archived" });
const custom = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
linkedChild,
]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true });
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ task: { id: "FN-9", column: "archived" } });
expect(custom.store.createTask).not.toHaveBeenCalled();
});
it("treats a linked child in synthesized v1 archived state as unavailable", async () => {
const archivedChild = task({ id: "FN-9", description: "Legacy archived child", column: "archived" });
const legacy = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
archivedChild,
]);
installLegacyV1RecommendationWorkflow(legacy.store, ["FN-9"]);
const response = await performRequest(
legacy.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(legacy.store.createTask).not.toHaveBeenCalled();
});
it("rejects an unavailable proposal claim in a custom workflow archived lane", async () => {
const unavailableClaim = task({
id: "FN-9",
description: "Archived recommendation claim",
column: "boxed" as Column,
proposalClaimId: "recommendation:FN-1:rec-1",
});
const custom = buildApp([parent(), unavailableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.linkTaskRecommendation).not.toHaveBeenCalled();
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBeUndefined();
});
it("repairs a proposal claim from an explicitly declared untraited archived column", async () => {
const liveClaim = task({
id: "FN-9",
description: "Live recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
});
const custom = buildApp([parent(), liveClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true });
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-9",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-9");
});
it("recovers a deterministic proposal claim from the legacy archived compatibility lane", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Archived recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const custom = buildApp([parent(), canonical, recoverableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-10"]);
(custom.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.unarchiveTask).not.toHaveBeenCalled();
expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true });
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it("recovers a deterministic proposal claim from a custom archived-trait lane", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Archived recommendation claim",
column: "boxed" as Column,
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const custom = buildApp([parent(), canonical, recoverableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-10"]);
(custom.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.unarchiveTask).not.toHaveBeenCalled();
expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true });
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it("recovers a deterministic proposal claim from synthesized v1 archived state", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Legacy archived recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const legacy = buildApp([parent(), canonical, recoverableClaim]);
installLegacyV1RecommendationWorkflow(legacy.store, ["FN-10"]);
(legacy.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
legacy.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(legacy.store.createTask).not.toHaveBeenCalled();
expect(legacy.store.unarchiveTask).not.toHaveBeenCalled();
expect(legacy.store.moveTask).toHaveBeenCalledWith("FN-10", "triage", { recoveryRehome: true });
expect(legacy.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(legacy.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it.each([ it.each([
{ bypassDuplicateCheck: true }, { bypassDuplicateCheck: true },
{ acknowledgedDuplicates: ["FN-9"] }, { acknowledgedDuplicates: ["FN-9"] },

View File

@@ -86,6 +86,7 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import { GitHubClient } from "../github.js"; import { GitHubClient } from "../github.js";
import { resolveArtifactMediaPath } from "../artifact-media.js"; import { resolveArtifactMediaPath } from "../artifact-media.js";
import { archivedColumnsForTask } from "../task-lifecycle-lanes.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js";
@@ -357,7 +358,6 @@ async function resolveTerminalColumnsForTask(store: TaskStore, taskId: string):
} }
} }
function isArtifactType(value: string): value is ArtifactType { function isArtifactType(value: string): value is ArtifactType {
return ARTIFACT_TYPES.has(value as ArtifactType); return ARTIFACT_TYPES.has(value as ArtifactType);
} }
@@ -2136,18 +2136,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
canonical lets the original claimed child return to its workflow-resolved intake lane. canonical lets the original claimed child return to its workflow-resolved intake lane.
*/ */
if (trusted?.recoverArchivedProposalTask) { if (trusted?.recoverArchivedProposalTask) {
const restoredTask = await scopedStore.unarchiveTask(trusted.recoverArchivedProposalTask.id);
/* /*
FNXC:TaskRecommendations 2026-08-08-08:44: FNXC:TaskRecommendations 2026-08-08-08:44:
A deterministic-duplicate archive is a lane move, not an operator archive, so it has no A deterministic-duplicate archive is a lane move, not an operator archive, so it has no
pre-archive history for generic restore to replay. Re-home its recovered child to that pre-archive history for generic restore to replay. Re-home its recovered child to that
child's workflow intake explicitly; otherwise custom workflows can restore it to a legacy child's workflow intake explicitly; otherwise custom workflows can restore it to a legacy
fallback or complete lane instead of the normal guarded-intake destination. fallback or complete lane instead of the normal guarded-intake destination.
FNXC:TaskRecommendations 2026-08-09-06:06:
Never call the cold-storage unarchive path here. Deterministic reconciliation keeps the row in
active storage and may move it to a custom archived-trait lane; re-home that live row directly
through the explicit recovery bypass because archive-to-intake is intentionally non-adjacent.
*/ */
const intakeColumn = await resolveIntakeColumnForTask(scopedStore, restoredTask.id); const archivedTask = trusted.recoverArchivedProposalTask;
const recoveredTask = restoredTask.column === intakeColumn const intakeColumn = await resolveIntakeColumnForTask(scopedStore, archivedTask.id);
? restoredTask const recoveredTask = archivedTask.column === intakeColumn
: await scopedStore.moveTask(restoredTask.id, intakeColumn); ? archivedTask
: await scopedStore.moveTask(archivedTask.id, intakeColumn, { recoveryRehome: true });
const trustedCreateResult = await trusted.onCreated?.(recoveredTask); const trustedCreateResult = await trusted.onCreated?.(recoveredTask);
res.status(200).json(trusted.responseForCreated?.(recoveredTask, trustedCreateResult) ?? recoveredTask); res.status(200).json(trusted.responseForCreated?.(recoveredTask, trustedCreateResult) ?? recoveredTask);
return; return;
@@ -2349,13 +2354,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw conflict("Recommendation link is malformed"); throw conflict("Recommendation link is malformed");
} }
const linked = await scopedStore.getTask(recommendation.createdTaskId).catch(() => null); const linked = await scopedStore.getTask(recommendation.createdTaskId).catch(() => null);
const linkedArchiveColumns = linked
? await archivedColumnsForTask(scopedStore, linked.id)
: new Set<string>();
/* /*
FNXC:TaskRecommendations 2026-08-08-06:34: FNXC:TaskRecommendations 2026-08-08-06:34:
A prior link is reusable only while its child remains in a live task lane. Archived and A prior link is reusable only while its child remains in a live task lane. Archived and
soft-deleted children are historical records, not an actionable Created result; conflict soft-deleted children are historical records, not an actionable Created result; conflict
rather than silently resurrecting or linking a second child. rather than silently resurrecting or linking a second child.
*/ */
if (!linked || linked.deletedAt || linked.column === "archived") { if (!linked || linked.deletedAt || linkedArchiveColumns.has(linked.column)) {
throw conflict("Recommendation link points to an unavailable task"); throw conflict("Recommendation link points to an unavailable task");
} }
return res.status(200).json({ task: linked, parent }); return res.status(200).json({ task: linked, parent });
@@ -2370,16 +2378,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
*/ */
const existing = (await scopedStore.listTasks({ slim: false, includeArchived: true, includeDeleted: true })) const existing = (await scopedStore.listTasks({ slim: false, includeArchived: true, includeDeleted: true }))
.find((task) => task.proposalClaimId === proposalClaimId); .find((task) => task.proposalClaimId === proposalClaimId);
const existingArchiveColumns = await (async () => { const existingArchiveColumns = existing
if (!existing) return new Set<string>(); ? await archivedColumnsForTask(scopedStore, existing.id)
try { : new Set<string>();
const ir = await resolveWorkflowIrForTask(scopedStore, existing.id);
const columns = columnsWithFlag(ir, "archived");
return new Set(columns.length > 0 ? columns : ["archived"]);
} catch {
return new Set(["archived"]);
}
})();
/* /*
FNXC:TaskRecommendations 2026-08-08-08:44: FNXC:TaskRecommendations 2026-08-08-08:44:
Deterministic reconciliation moves a child to its workflow's archived trait, which may be Deterministic reconciliation moves a child to its workflow's archived trait, which may be

View File

@@ -1,4 +1,4 @@
import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, type WorkflowIr } from "@fusion/core"; import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, workflowHasColumn, type WorkflowIr } from "@fusion/core";
/* /*
FNXC:WorkflowResolvedColumns 2026-07-30-08:45 (#2783 review — coderabbit): FNXC:WorkflowResolvedColumns 2026-07-30-08:45 (#2783 review — coderabbit):
@@ -46,6 +46,14 @@ legacy fallback as a workflow that cannot be read at all.
*/ */
const LEGACY_LANDED_COLUMNS: readonly string[] = ["done", "archived"]; const LEGACY_LANDED_COLUMNS: readonly string[] = ["done", "archived"];
function archivedColumnsForIr(ir: WorkflowIr): Set<string> {
if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]);
const archived = columnsWithFlag(ir, "archived");
return workflowHasColumn(ir, "archived")
? new Set(archived)
: new Set([...archived, "archived"]);
}
export async function landedColumnsForTask( export async function landedColumnsForTask(
store: LaneResolverStore, store: LaneResolverStore,
taskId: string, taskId: string,
@@ -53,8 +61,8 @@ export async function landedColumnsForTask(
): Promise<Set<string>> { ): Promise<Set<string>> {
try { try {
const ir = await resolveWorkflowIrForTask(store, taskId, irCache); const ir = await resolveWorkflowIrForTask(store, taskId, irCache);
const landed = [...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")]; if (!declaresAnyLifecycleTrait(ir)) return new Set(LEGACY_LANDED_COLUMNS);
return new Set(landed.length > 0 ? landed : LEGACY_LANDED_COLUMNS); return new Set([...columnsWithFlag(ir, "complete"), ...archivedColumnsForIr(ir)]);
} catch { } catch {
return new Set(LEGACY_LANDED_COLUMNS); return new Set(LEGACY_LANDED_COLUMNS);
} }
@@ -108,6 +116,11 @@ Falling back onto it widens the guard onto a role the board explicitly did not a
`declaresAnyLifecycleTrait` separates the two, matching the shape #2821's review established for `declaresAnyLifecycleTrait` separates the two, matching the shape #2821's review established for
`resolveNodeOverrideLanes`. A board that traits nothing keeps the legacy id; a board that traits `resolveNodeOverrideLanes`. A board that traits nothing keeps the legacy id; a board that traits
something is taken at its word, including when the answer is "no such lane". something is taken at its word, including when the answer is "no such lane".
FNXC:TaskRecommendations 2026-08-09-06:06:
An undeclared legacy `archived` id remains a compatibility tombstone even after a workflow adopts
lifecycle traits. Only an explicitly declared untraited `archived` column proves that id is live;
archived-trait lanes remain additive because persisted pre-migration tasks can still use the old id.
*/ */
export async function archivedColumnsForTask( export async function archivedColumnsForTask(
store: LaneResolverStore, store: LaneResolverStore,
@@ -116,8 +129,7 @@ export async function archivedColumnsForTask(
): Promise<Set<string>> { ): Promise<Set<string>> {
try { try {
const ir = await resolveWorkflowIrForTask(store, taskId, irCache); const ir = await resolveWorkflowIrForTask(store, taskId, irCache);
if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]); return archivedColumnsForIr(ir);
return new Set(columnsWithFlag(ir, "archived"));
} catch { } catch {
return new Set(["archived"]); return new Set(["archived"]);
} }