From cd433bd68d225515089a18e1a394fab20c191b53 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 22:16:34 -0700 Subject: [PATCH] fix(dashboard): map unresolvable-workflow triage errors to 404; complete stale route fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...fix-mission-triage-unknown-workflow-404.md | 7 ++++ .../plan-approval-intake-column.test.ts | 18 ++++++++- .../src/__tests__/routes-github.test.ts | 37 ++++++++++++++++++- .../routes-task-retry-planning-column.test.ts | 2 + ...utes-task-retry-stale-merge-status.test.ts | 2 + packages/dashboard/src/__tests__/sse.test.ts | 3 ++ packages/dashboard/src/mission-routes.ts | 29 +++++++++++++++ ...ster-config-mcp-pi-settings-routes.test.ts | 15 ++++++-- 8 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-mission-triage-unknown-workflow-404.md diff --git a/.changeset/fix-mission-triage-unknown-workflow-404.md b/.changeset/fix-mission-triage-unknown-workflow-404.md new file mode 100644 index 0000000000..2c39d6009c --- /dev/null +++ b/.changeset/fix-mission-triage-unknown-workflow-404.md @@ -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. diff --git a/packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts b/packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts index 01d41ead70..93022744f2 100644 --- a/packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts +++ b/packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts @@ -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 { + /* + 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()), diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index a4bd8023bf..19988f0860 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -234,6 +234,19 @@ function createMockStore(overrides: Partial = {}): 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) => ({ ...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(); diff --git a/packages/dashboard/src/__tests__/routes-task-retry-planning-column.test.ts b/packages/dashboard/src/__tests__/routes-task-retry-planning-column.test.ts index bef032a2f9..b2af57014d 100644 --- a/packages/dashboard/src/__tests__/routes-task-retry-planning-column.test.ts +++ b/packages/dashboard/src/__tests__/routes-task-retry-planning-column.test.ts @@ -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, diff --git a/packages/dashboard/src/__tests__/routes-task-retry-stale-merge-status.test.ts b/packages/dashboard/src/__tests__/routes-task-retry-stale-merge-status.test.ts index 6bdb76f123..ddc7d7bb56 100644 --- a/packages/dashboard/src/__tests__/routes-task-retry-stale-merge-status.test.ts +++ b/packages/dashboard/src/__tests__/routes-task-retry-stale-merge-status.test.ts @@ -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() }; diff --git a/packages/dashboard/src/__tests__/sse.test.ts b/packages/dashboard/src/__tests__/sse.test.ts index 257396e209..7ee1108e90 100644 --- a/packages/dashboard/src/__tests__/sse.test.ts +++ b/packages/dashboard/src/__tests__/sse.test.ts @@ -52,6 +52,8 @@ function createMockStore(settings: Record = {}): 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(); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 24eb23c483..42a7cf1eb1 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -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); } diff --git a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts index ddeab6d9c7..8c9f6352c7 100644 --- a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts @@ -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 () => {