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:
7
.changeset/fix-mission-triage-unknown-workflow-404.md
Normal file
7
.changeset/fix-mission-triage-unknown-workflow-404.md
Normal 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.
|
||||||
@@ -18,7 +18,7 @@ fails with 400 instead of succeeding, because these cards are in `todo`.
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { TaskStore, TaskDetail } from "@fusion/core";
|
import type { TaskStore, TaskDetail } from "@fusion/core";
|
||||||
@@ -56,9 +56,23 @@ const PLANNING_TASK: TaskDetail = {
|
|||||||
} as unknown as TaskDetail;
|
} as unknown as TaskDetail;
|
||||||
|
|
||||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
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 {
|
return {
|
||||||
getSettings: vi.fn().mockResolvedValue({}),
|
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),
|
getTask: vi.fn().mockResolvedValue(PLANNING_TASK),
|
||||||
updateTask: vi.fn().mockResolvedValue(PLANNING_TASK),
|
updateTask: vi.fn().mockResolvedValue(PLANNING_TASK),
|
||||||
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await fn()),
|
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await fn()),
|
||||||
|
|||||||
@@ -234,6 +234,19 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
moveTask: vi.fn(),
|
moveTask: vi.fn(),
|
||||||
updateTask: vi.fn(),
|
updateTask: vi.fn(),
|
||||||
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await 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(),
|
deleteTask: vi.fn(),
|
||||||
mergeTask: vi.fn(),
|
mergeTask: vi.fn(),
|
||||||
archiveTask: vi.fn(),
|
archiveTask: vi.fn(),
|
||||||
@@ -2647,17 +2660,32 @@ describe("POST /tasks/:id/spec/rebuild", () => {
|
|||||||
|
|
||||||
describe("POST /tasks/:id/approve-plan", () => {
|
describe("POST /tasks/:id/approve-plan", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
let approvalRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
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({
|
store = createMockStore({
|
||||||
getTask: vi.fn(),
|
getTask: vi.fn(),
|
||||||
moveTask: vi.fn(),
|
moveTask: vi.fn(),
|
||||||
updateTask: vi.fn(),
|
updateTask: vi.fn(),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
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() {
|
function buildApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -2816,9 +2844,14 @@ describe("POST /tasks/:id/approve-plan", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
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", {
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||||
status: null,
|
status: null,
|
||||||
approvedPlanFingerprint: null,
|
approvedPlanFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||||
});
|
});
|
||||||
expect(res.body.column).toBe("todo");
|
expect(res.body.column).toBe("todo");
|
||||||
expect(res.body.status).toBeUndefined();
|
expect(res.body.status).toBeUndefined();
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ function buildApp(input: { task: Task; workflowId?: string; definition?: unknown
|
|||||||
// no-op (`force: true`) rather than touching any real tree.
|
// no-op (`force: true`) rather than touching any real tree.
|
||||||
getRootDir: () => "/tmp/fusion-retry-planning-column-does-not-exist",
|
getRootDir: () => "/tmp/fusion-retry-planning-column-does-not-exist",
|
||||||
listTasks: async () => [input.task],
|
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),
|
getTaskWorkflowSelectionAsync: async () => (input.workflowId ? { workflowId: input.workflowId } : null),
|
||||||
getWorkflowDefinition: async () => input.definition,
|
getWorkflowDefinition: async () => input.definition,
|
||||||
getWorkflowSettingsProjectId: () => undefined,
|
getWorkflowSettingsProjectId: () => undefined,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ function buildApp(input: { task: Task; activeMergeTaskId?: string | null; staleM
|
|||||||
getSettingsFast: async () => ({}),
|
getSettingsFast: async () => ({}),
|
||||||
getRootDir: () => "/tmp/does-not-exist",
|
getRootDir: () => "/tmp/does-not-exist",
|
||||||
listTasks: async () => [input.task],
|
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;
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
const runtimeLogger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() };
|
const runtimeLogger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() };
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ function createMockStore(settings: Record<string, unknown> = {}): TaskStore {
|
|||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
getResearchStore: vi.fn(() => researchStore),
|
getResearchStore: vi.fn(() => researchStore),
|
||||||
getSettings: vi.fn(async () => settings),
|
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;
|
} as unknown as TaskStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,6 +606,7 @@ describe("createSSE client cleanup", () => {
|
|||||||
}),
|
}),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
getResearchStore: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
|
getResearchStore: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
|
||||||
|
getAsyncLayer: vi.fn(() => null),
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
const baseline = getActiveSSEConnections();
|
const baseline = getActiveSSEConnections();
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ function validateOptionalWorkflowId(workflowId: unknown): string | null | undefi
|
|||||||
throw badRequest("workflowId must be a string or null");
|
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:
|
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.
|
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")) {
|
if (errMsg.includes("TaskStore")) {
|
||||||
throw new ApiError(503, "TaskStore not available for triage operations");
|
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)) {
|
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
|
||||||
throw notFound(errMsg);
|
throw notFound(errMsg);
|
||||||
}
|
}
|
||||||
@@ -3100,6 +3125,10 @@ export function createMissionRouter(
|
|||||||
if (errMsg.includes("TaskStore")) {
|
if (errMsg.includes("TaskStore")) {
|
||||||
throw new ApiError(503, "TaskStore not available for triage operations");
|
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)) {
|
if (/workflow/i.test(errMsg) && /not found/i.test(errMsg)) {
|
||||||
throw notFound(errMsg);
|
throw notFound(errMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,18 @@ describe("registerConfigMcpPiSettingsRoutes", () => {
|
|||||||
]), "GET", "/mcp/plugin-servers?projectId=project-a");
|
]), "GET", "/mcp/plugin-servers?projectId=project-a");
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
it("rejects malformed MCP validation bodies", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user