diff --git a/.changeset/fn-8695-assertion-id-validation.md b/.changeset/fn-8695-assertion-id-validation.md new file mode 100644 index 0000000000..56c399817b --- /dev/null +++ b/.changeset/fn-8695-assertion-id-validation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mission assertion status edits and deletes failing with invalid assertion IDs. +category: fix +dev: Align dashboard assertion ID validation with MissionStore-generated IDs and surface delete failures. diff --git a/docs/missions.md b/docs/missions.md index 7778c4e0a8..cf2fe862da 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -451,7 +451,7 @@ Contract assertions (`MissionContractAssertion`) formalize what must be true for ```typescript interface MissionContractAssertion { - id: string; // e.g., "CA-A3B7CD-E9F2" + id: string; // e.g., "CA-MS39KJP3-000A-8ABO" (legacy: "CA-A3B7CD-E9F2") milestoneId: string; // Parent milestone sourceFeatureId?: string;// Store-managed feature assertion owner scope: "feature" | "milestone"; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 7d5aa1b452..90efd0d32f 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -88,6 +88,7 @@ import { fetchAssertions, createAssertion, updateAssertion, + deleteAssertion, linkFeatureToAssertion, unlinkFeatureFromAssertion, fetchFeaturesForAssertion, @@ -940,7 +941,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [isActive, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError]); // Delete confirmation - const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null); + const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string; milestoneId?: string } | null>(null); // Assertion panel state const [assertionsByMilestone, setAssertionsByMilestone] = useState>(new Map()); @@ -2212,6 +2213,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } }, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); + const handleDeleteAssertion = useCallback(async (assertionId: string, milestoneId: string) => { + await deleteAssertion(assertionId, projectId); + addToast(t("missions.assertionDeleted", "Assertion deleted"), "success"); + await loadAssertionsForMilestone(milestoneId); + await loadValidationRollup(milestoneId); + setDeleteConfirmId(null); + }, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); + const handleEditAssertion = useCallback((assertion: MissionContractAssertion) => { setEditingAssertionId(assertion.id); setAssertionForm({ @@ -4065,8 +4074,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr @@ -4975,6 +4985,30 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr ); }; + /* + FNXC:MissionAssertions 2026-08-01-19:44: + Every deleteConfirmId type must dispatch a deletion, and the shared confirmation panel must surface rejected requests. Assertion deletion is an operator recovery path for validation failures, so a silent no-op would leave stale rollups unrepairable. + */ + const handleConfirmDelete = useCallback(async () => { + if (!deleteConfirmId) return; + + try { + if (deleteConfirmId.type === "milestone") { + await handleDeleteMilestone(deleteConfirmId.id); + } else if (deleteConfirmId.type === "slice") { + await handleDeleteSlice(deleteConfirmId.id); + } else if (deleteConfirmId.type === "feature") { + await handleDeleteFeature(deleteConfirmId.id); + } else if (deleteConfirmId.type === "assertion" && deleteConfirmId.milestoneId) { + await handleDeleteAssertion(deleteConfirmId.id, deleteConfirmId.milestoneId); + } else if (deleteConfirmId.type === "interview_draft") { + await handleDiscardInterviewSession(deleteConfirmId.id); + } + } catch (err) { + addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete item"), "error"); + } + }, [addToast, deleteConfirmId, handleDeleteAssertion, handleDeleteFeature, handleDeleteMilestone, handleDeleteSlice, handleDiscardInterviewSession, t]); + const renderDeleteConfirmPanel = () => { if (deleteConfirmId?.type === "mission") { return null; @@ -4992,18 +5026,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
diff --git a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx index ccab02b103..70ceee8a23 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx @@ -18,6 +18,7 @@ const mockFetchAiSession = vi.fn(); const mockFetchMissionInterviewDrafts = vi.fn(); const mockDiscardMissionInterviewDraft = vi.fn(); const mockDeleteMission = vi.fn(); +const mockDeleteAssertion = vi.fn(); const mockSubscribeSse = vi.fn(() => vi.fn()); vi.mock("../../hooks/useViewportMode", () => ({ @@ -68,6 +69,7 @@ vi.mock("../../api", async (importOriginal) => { fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args), discardMissionInterviewDraft: (...args: unknown[]) => mockDiscardMissionInterviewDraft(...args), deleteMission: (...args: unknown[]) => mockDeleteMission(...args), + deleteAssertion: (...args: unknown[]) => mockDeleteAssertion(...args), fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), }; }); @@ -139,6 +141,7 @@ function setupMocks() { mockFetchMissionInterviewDrafts.mockResolvedValue([]); mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true }); mockDeleteMission.mockResolvedValue(undefined); + mockDeleteAssertion.mockResolvedValue(undefined); } function renderMissionManager(addToast = vi.fn()) { @@ -190,6 +193,49 @@ async function findMissionListItem(title: string): Promise { return item as HTMLElement; } +function makeAssertion(id: string) { + return { + id, + milestoneId: "MS-001", + title: `Assertion ${id}`, + assertion: "The contract holds", + status: "pending" as const, + orderIndex: 0, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function missionWithAssertions() { + return { + ...missionDetails.get("M-001"), + milestones: [{ + id: "MS-001", + missionId: "M-001", + title: "Quality gate", + status: "active", + orderIndex: 0, + interviewState: "not_started", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + slices: [], + }], + }; +} + +async function openAssertionDeletePanel(assertionId: string) { + fireEvent.click(await findMissionListItem("Build Auth System")); + await screen.findByText("Quality gate"); + const assertion = await screen.findByText(`Assertion ${assertionId}`); + const row = assertion.closest(".mission-assertion"); + expect(row).not.toBeNull(); + const deleteButton = (row as HTMLElement).querySelector('button[title="Delete assertion"]'); + expect(deleteButton).not.toBeNull(); + fireEvent.click(deleteButton as HTMLButtonElement); + return getConfirmPanel(); +} + async function openListDeleteDialog(title: string, container: HTMLElement) { const item = await findMissionListItem(title); fireEvent.click(within(item).getByRole("button", { name: "Delete mission" })); @@ -286,6 +332,59 @@ describe("MissionManager mission delete confirmation", () => { expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error"); }); + it.each(["desktop", "mobile"] as const)("deletes an assertion and refreshes its milestone in the %s panel", async (viewport) => { + mockViewportMode.mockReturnValue(viewport); + mockFetchMission.mockResolvedValue(missionWithAssertions()); + mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-TEST")]); + renderMissionManager(); + + await openAssertionDeletePanel("CA-CURRENT-0001-TEST"); + clickConfirmPanelAction("Delete"); + + await waitFor(() => { + expect(mockDeleteAssertion).toHaveBeenCalledWith("CA-CURRENT-0001-TEST", projectId); + }); + await waitFor(() => { + expect(mockFetchAssertions.mock.calls.filter(([milestoneId]) => milestoneId === "MS-001")).toHaveLength(2); + expect(mockFetchMilestoneValidation).toHaveBeenCalledWith("MS-001", projectId); + }); + }); + + it("surfaces an assertion delete failure instead of silently swallowing it", async () => { + const addToast = vi.fn(); + mockFetchMission.mockResolvedValue(missionWithAssertions()); + mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-FAIL")]); + mockDeleteAssertion.mockRejectedValueOnce(new Error("assertion delete failed upstream")); + renderMissionManager(addToast); + + const panel = await openAssertionDeletePanel("CA-CURRENT-0001-FAIL"); + clickConfirmPanelAction("Delete"); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith("assertion delete failed upstream", "error"); + }); + expect(panel).toBeInTheDocument(); + }); + + it("keeps zero and multiple assertion states distinct", async () => { + mockFetchMission.mockResolvedValue(missionWithAssertions()); + mockFetchAssertions.mockResolvedValueOnce([]).mockResolvedValueOnce([ + makeAssertion("CA-CURRENT-0001-FIRST"), + makeAssertion("CA-CURRENT-0002-SECOND"), + ]); + renderMissionManager(); + + fireEvent.click(await findMissionListItem("Build Auth System")); + const milestone = await screen.findByText("Quality gate"); + await waitFor(() => { + expect(document.querySelectorAll(".mission-assertion")).toHaveLength(0); + }); + fireEvent.click(milestone); + fireEvent.click(milestone); + expect(await screen.findByText("Assertion CA-CURRENT-0001-FIRST")).toBeInTheDocument(); + expect(screen.getByText("Assertion CA-CURRENT-0002-SECOND")).toBeInTheDocument(); + }); + it("discards the selected desktop draft row without removing duplicate-titled drafts or missions", async () => { mockFetchMissionInterviewDrafts.mockResolvedValue([ makeDraft({ id: "draft-duplicate-a", title: "Shared draft", status: "awaiting_input" }), diff --git a/packages/dashboard/src/__tests__/mission-assertion-id-validation.test.ts b/packages/dashboard/src/__tests__/mission-assertion-id-validation.test.ts new file mode 100644 index 0000000000..2f75d6aeff --- /dev/null +++ b/packages/dashboard/src/__tests__/mission-assertion-id-validation.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +/* +FNXC:MissionAssertions 2026-08-01-19:44: +Route validation must accept IDs emitted by MissionStore rather than a hand-written approximation. This protects every assertion CRUD and feature-link route when the store's generated ID segments evolve. +*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import { MissionStore, type Database, type TaskStore } from "@fusion/core"; +import { createMissionRouter } from "../mission-routes.js"; +import { request } from "../test-request.js"; + +const FEATURE_ID = "F-TEST"; +const LEGACY_ASSERTION_ID = "CA-A3B7CD-E9F2"; + +function createStoreGeneratedAssertionId(): string { + const database = { + prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }), + bumpLastModified: vi.fn(), + } as unknown as Database; + const missionStore = new MissionStore("/tmp/mission-assertion-id-validation", database); + + return (missionStore as unknown as { generateId(prefix: string): string }).generateId("CA"); +} + +function createFixture() { + const assertionId = createStoreGeneratedAssertionId(); + const assertion = { id: assertionId, milestoneId: "MS-TEST", title: "Assertion", assertion: "It holds", status: "pending" }; + const missionStore = { + getContractAssertion: vi.fn(async (id: string) => id.startsWith("CA-") ? { ...assertion, id } : undefined), + updateContractAssertion: vi.fn(async (id: string) => ({ ...assertion, id, status: "passed" })), + deleteContractAssertion: vi.fn(async () => undefined), + linkFeatureToAssertion: vi.fn(async () => undefined), + unlinkFeatureFromAssertion: vi.fn(async () => undefined), + listFeaturesForAssertion: vi.fn(async () => [{ id: FEATURE_ID }]), + on: vi.fn(), + off: vi.fn(), + }; + const store = { + getMissionStore: () => missionStore, + getGoalStore: () => ({ getGoal: vi.fn(), listGoals: vi.fn() }), + getRootDir: () => "/tmp/mission-assertion-id-validation", + getSettings: vi.fn(async () => ({})), + backendMode: true, + } as unknown as TaskStore; + const app = express(); + app.use(express.json()); + app.use("/api/missions", createMissionRouter(store)); + + return { app, assertionId, missionStore }; +} + +describe("mission assertion ID validation", () => { + let fixture: ReturnType; + + beforeEach(() => { + fixture = createFixture(); + }); + + it.each([ + ["GET /assertions/:assertionId", "GET", (id: string) => `/api/missions/assertions/${id}`], + ["PATCH /assertions/:assertionId", "PATCH", (id: string) => `/api/missions/assertions/${id}`], + ["DELETE /assertions/:assertionId", "DELETE", (id: string) => `/api/missions/assertions/${id}`], + ["POST /features/:featureId/assertions/:assertionId/link", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/link`], + ["POST /features/:featureId/assertions/:assertionId/unlink", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/unlink`], + ["GET /assertions/:assertionId/features", "GET", (id: string) => `/api/missions/assertions/${id}/features`], + ] as const)("accepts a current MissionStore ID on %s", async (_name, method, pathFor) => { + const response = await request( + fixture.app, + method, + pathFor(fixture.assertionId), + method === "PATCH" ? JSON.stringify({ status: "passed" }) : undefined, + method === "PATCH" ? { "content-type": "application/json" } : undefined, + ); + + expect(fixture.assertionId).toMatch(/^CA-[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/); + expect(response.status).not.toBe(400); + expect(response.body).not.toEqual({ error: "Invalid assertion ID format" }); + }); + + it("accepts legacy two-segment assertion IDs", async () => { + const response = await request(fixture.app, "GET", `/api/missions/assertions/${LEGACY_ASSERTION_ID}`); + + expect(response.status).toBe(200); + }); + + it.each([ + "M-A3B7CD-0001-E9F2", + "CA-", + "%20", + "CA-%2E%2E%2F%2E%2E%2Fetc", + "CA-A3B7CD%2F0001-E9F2", + "CA-A3B7CD-0001-INVALID!", + ])("rejects invalid assertion ID %j", async (assertionId) => { + const response = await request(fixture.app, "GET", `/api/missions/assertions/${assertionId}`); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "Invalid assertion ID format" }); + }); +}); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 72fb712a63..bf61f1db06 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -155,10 +155,12 @@ function validateOptionalWorkflowId(workflowId: unknown): string | null | undefi throw badRequest("workflowId must be a string or null"); } +/* +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. +*/ function validateAssertionId(id: string): boolean { - // Assertion IDs follow format: CA-{base36timestamp}-{random} - // e.g., CA-A3B7CD-E9F2 - return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id); + return /^CA-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id); } function validateGoalId(id: string): boolean {