fix(dashboard): map unresolvable-workflow triage errors to 404; complete stale route fixtures

Dashboard bare-run repair, route/store cluster. Real product regression
fixed: the FNXC:IntakeOwnership boundary replaced the 'Workflow ... not
found' message with a typed TaskIntakeOwnerResolutionError, so the
mission triage routes' message-pattern mapping stopped firing and an
unknown workflowId leaked as a 500 — both feature and slice triage
handlers now match the typed error structurally and return 404 (patch
changeset included). Everything else was stale fixtures behind the PG
cutover and recent seams: sse's mock stores learn getAsyncLayer, the
retry fixtures learn FN-8908's resetTerminalFailureAutoRecoveryBudget,
approve-plan tests materialize a real on-disk PROMPT.md per the
SpecLockApproval 409 contract (fingerprint assertion strengthened to
the always-hash contract), and the MCP settings route body pins the new
host-dependent fusionMemoryMcpAvailable field. Verified 7 files / 213
tests green under their assigned lane projects, src typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-15 22:16:34 -07:00
parent 5c5ffb6f48
commit cd433bd68d
8 changed files with 106 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mission triage with an unknown workflowId now returns 404 instead of a 500.
category: fix
dev: mission-routes.ts maps core's TaskIntakeOwnerResolutionError (reason "workflow-unresolvable") to notFound in both the feature and slice triage handlers via a structural code+reason match; the old message-pattern mapping stopped firing after the FNXC:IntakeOwnership boundary introduced the typed error.

View File

@@ -18,7 +18,7 @@ fails with 400 instead of succeeding, because these cards are in `todo`.
*/
import { describe, it, expect, vi } from "vitest";
import express from "express";
import { mkdtempSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { TaskStore, TaskDetail } from "@fusion/core";
@@ -56,9 +56,23 @@ const PLANNING_TASK: TaskDetail = {
} as unknown as TaskDetail;
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
/*
FNXC:SpecLockApproval 2026-08-15-05:10:
approve-plan now refuses (409) when the approved card's PROMPT.md is unreadable — approval is
the spec-lock release boundary. Materialize the fixture card's on-disk plan so the merged-intake
approval case exercises the success path, and mock the spec-lock/drift seams the route calls
inside the planning fence.
*/
const root = mkdtempSync(join(tmpdir(), "kb-plan-approval-"));
mkdirSync(join(root, ".fusion", "tasks", "FN-200"), { recursive: true });
writeFileSync(join(root, ".fusion", "tasks", "FN-200", "PROMPT.md"), "# Plan\n");
return {
getSettings: vi.fn().mockResolvedValue({}),
getRootDir: vi.fn().mockReturnValue(mkdtempSync(join(tmpdir(), "kb-plan-approval-"))),
getRootDir: vi.fn().mockReturnValue(root),
lockCurrentPlanWhilePlanningLocked: vi.fn().mockResolvedValue(undefined),
reconcileSpecDriftWhilePlanningLocked: vi.fn().mockResolvedValue(undefined),
// FNXC:TaskWedgeNotifications 2026-08-15-05:10: dashboard Retry clears the generic-terminal auto-recovery budget before mutating task state.
resetTerminalFailureAutoRecoveryBudget: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue(PLANNING_TASK),
updateTask: vi.fn().mockResolvedValue(PLANNING_TASK),
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await fn()),

View File

@@ -234,6 +234,19 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
moveTask: vi.fn(),
updateTask: vi.fn(),
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await fn()),
// FNXC:SpecLockApproval 2026-08-15-05:10: approve-plan now locks the approved PROMPT.md and publishes a drift report inside the planning fence; mock both seams so route contracts stay isolated from spec-lock persistence.
lockCurrentPlanWhilePlanningLocked: vi.fn().mockResolvedValue(undefined),
reconcileSpecDriftWhilePlanningLocked: vi.fn().mockResolvedValue(undefined),
/*
FNXC:PlanApprovalDispatch 2026-08-15-05:10:
The engine mock's resumeApprovedPlanReviewHandoff defaults to the REAL implementation, which
seeds a runnable Plan Review continuation through these store writers after approval. Provide
the empty/no-op continuation surface so route unit tests exercise the genuine handoff instead
of exploding with "store.listWorkflowWorkItemsForTask is not a function".
*/
listWorkflowWorkItemsForTask: vi.fn().mockResolvedValue([]),
seedStrandedPlanReviewContinuation: vi.fn(async () => ({ seeded: true, workItemId: "mock-plan-review-continuation" })),
replaceActiveTaskWorkflowContinuation: vi.fn(async (input: Record<string, unknown>) => ({ ...input, id: "mock-plan-review-continuation" })),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
@@ -2647,17 +2660,32 @@ describe("POST /tasks/:id/spec/rebuild", () => {
describe("POST /tasks/:id/approve-plan", () => {
let store: TaskStore;
let approvalRoot: string;
beforeEach(() => {
/*
FNXC:SpecLockApproval 2026-08-15-05:10:
Approval is a release boundary — an unreadable PROMPT.md is now a 409 (the route refuses to
approve without creating the immutable spec lock). The mock root must therefore carry a real
on-disk PROMPT.md for the fixture task, matching production where an awaiting-approval card
always has its spec materialized.
*/
approvalRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-approve-plan-root-"));
mkdirSync(join(approvalRoot, ".fusion", "tasks", "FN-001"), { recursive: true });
writeFileSync(join(approvalRoot, ".fusion", "tasks", "FN-001", "PROMPT.md"), "# Task: FN-001\n\nPlan body.\n");
store = createMockStore({
getTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getRootDir: vi.fn().mockReturnValue(approvalRoot),
});
});
afterEach(() => {
rmSync(approvalRoot, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
@@ -2816,9 +2844,14 @@ describe("POST /tasks/:id/approve-plan", () => {
expect(res.status).toBe(200);
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
/*
FNXC:SpecLockApproval 2026-08-15-05:10:
A readable PROMPT.md is now mandatory at approval (unreadable is a 409), so the persisted
patch always carries the real fingerprint hash of the approved on-disk plan — never null.
*/
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: null,
approvedPlanFingerprint: null,
approvedPlanFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
});
expect(res.body.column).toBe("todo");
expect(res.body.status).toBeUndefined();

View File

@@ -109,6 +109,8 @@ function buildApp(input: { task: Task; workflowId?: string; definition?: unknown
// no-op (`force: true`) rather than touching any real tree.
getRootDir: () => "/tmp/fusion-retry-planning-column-does-not-exist",
listTasks: async () => [input.task],
// FNXC:TaskWedgeNotifications 2026-08-15-05:10: dashboard Retry now clears the spent generic-terminal auto-recovery budget before mutating task state; the fixture must expose the seam or every retry 500s.
resetTerminalFailureAutoRecoveryBudget: async () => {},
getTaskWorkflowSelectionAsync: async () => (input.workflowId ? { workflowId: input.workflowId } : null),
getWorkflowDefinition: async () => input.definition,
getWorkflowSettingsProjectId: () => undefined,

View File

@@ -75,6 +75,8 @@ function buildApp(input: { task: Task; activeMergeTaskId?: string | null; staleM
getSettingsFast: async () => ({}),
getRootDir: () => "/tmp/does-not-exist",
listTasks: async () => [input.task],
// FNXC:TaskWedgeNotifications 2026-08-15-05:10: dashboard Retry now clears the spent generic-terminal auto-recovery budget before mutating task state; the fixture must expose the seam or every retry 500s.
resetTerminalFailureAutoRecoveryBudget: async () => {},
} as unknown as TaskStore;
const runtimeLogger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() };

View File

@@ -52,6 +52,8 @@ function createMockStore(settings: Record<string, unknown> = {}): TaskStore {
off: vi.fn(),
getResearchStore: vi.fn(() => researchStore),
getSettings: vi.fn(async () => settings),
// FNXC:AgentActivityStream 2026-08-15-05:10: sse.ts tails the durable agent-activity outbox via store.getAsyncLayer(); a null layer disables the tail, matching pre-PG mock stores.
getAsyncLayer: vi.fn(() => null),
} as unknown as TaskStore;
}
@@ -604,6 +606,7 @@ describe("createSSE client cleanup", () => {
}),
off: vi.fn(),
getResearchStore: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
getAsyncLayer: vi.fn(() => null),
} as unknown as TaskStore;
const baseline = getActiveSSEConnections();

View File

@@ -160,6 +160,19 @@ function validateOptionalWorkflowId(workflowId: unknown): string | null | undefi
throw badRequest("workflowId must be a string or null");
}
/*
FNXC:MissionWorkflows 2026-08-15-05:10:
Structural match on core's TaskIntakeOwnerResolutionError (the class is not exported from
@fusion/core's index). `workflow-unresolvable` means the client named a workflow whose
definition does not exist — a 4xx concern for the triage routes; every other reason stays a
genuine server-side failure.
*/
function isUnresolvableWorkflowIntakeError(err: unknown): boolean {
return err instanceof Error
&& (err as { code?: unknown }).code === "task-intake-owner-resolution"
&& (err as { reason?: unknown }).reason === "workflow-unresolvable";
}
/*
FNXC:MissionAssertions 2026-08-01-19:44:
The assertion guard landed on 2026-04-11 for two-segment IDs, but MissionStore.generateId added its idSequence segment on 2026-05-04. Keep this validator aligned with every dash-separated alphanumeric segment emitted by MissionStore while preserving legacy assertion rows.
@@ -3055,6 +3068,18 @@ export function createMissionRouter(
if (errMsg.includes("TaskStore")) {
throw new ApiError(503, "TaskStore not available for triage operations");
}
/*
FNXC:MissionWorkflows 2026-08-15-05:10:
Core's intake-ownership boundary (FNXC:IntakeOwnership 2026-08-09) now rejects a
client-named missing workflow with the typed TaskIntakeOwnerResolutionError
(code "task-intake-owner-resolution", reason "workflow-unresolvable") instead of a
"Workflow ... not found" message, so the message-pattern mapping below stopped firing
and an unknown workflowId leaked as a 500. Map the typed rejection back to 404: it is
client input, not a server fault, and no feature/task link is created.
*/
if (isUnresolvableWorkflowIntakeError(err)) {
throw notFound("Workflow not found for triage");
}
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
throw notFound(errMsg);
}
@@ -3100,6 +3125,10 @@ export function createMissionRouter(
if (errMsg.includes("TaskStore")) {
throw new ApiError(503, "TaskStore not available for triage operations");
}
// FNXC:MissionWorkflows 2026-08-15-05:10: see the single-feature triage handler above — the typed intake-ownership rejection replaced the "Workflow ... not found" message for a missing client-named workflow.
if (isUnresolvableWorkflowIntakeError(err)) {
throw notFound("Workflow not found for triage");
}
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
throw notFound(errMsg);
}

View File

@@ -56,9 +56,18 @@ describe("registerConfigMcpPiSettingsRoutes", () => {
]), "GET", "/mcp/plugin-servers?projectId=project-a");
expect(response.status).toBe(200);
expect(response.body).toEqual({ servers: [
{ pluginId: "enabled", server: { name: "navigator", transport: "stdio", command: "roslyn" } },
] });
/*
FNXC:MemoryMcp 2026-08-15-05:10:
The route now also reports whether Fusion's built-in memory MCP entry resolves on this host
(a Node filesystem probe, deliberately outside the SPA bundle). Its value is environment-
dependent, so pin its presence and type while keeping the server list exact.
*/
expect(response.body).toEqual({
servers: [
{ pluginId: "enabled", server: { name: "navigator", transport: "stdio", command: "roslyn" } },
],
fusionMemoryMcpAvailable: expect.any(Boolean),
});
});
it("rejects malformed MCP validation bodies", async () => {