diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index edc10e8a85..84298f54fa 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -190,6 +190,53 @@ describe("useTasks", () => { expect(result.current.tasks[0]?.releaseGate).toBeUndefined(); }); + it("prunes two release verdicts at their individual earliest expiries", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const now = new Date().toISOString(); + const later = new Date(Date.now() + 10_000).toISOString(); + const gate = (evaluatedAt: string) => ({ + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt, evaluatedForUpdatedAt: now, + }); + mockFetchTasks.mockResolvedValue([ + createMockTask({ id: "FN-EARLY", updatedAt: now, releaseGate: gate(now) }), + createMockTask({ id: "FN-LATE", updatedAt: now, releaseGate: gate(later) }), + ]); + + const { result } = renderHook(() => useTasks({ sseEnabled: false })); + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + act(() => vi.advanceTimersByTime(30_001)); + expect(result.current.tasks.find((task) => task.id === "FN-EARLY")?.releaseGate).toBeUndefined(); + expect(result.current.tasks.find((task) => task.id === "FN-LATE")?.releaseGate).toBeDefined(); + act(() => vi.advanceTimersByTime(10_000)); + expect(result.current.tasks.find((task) => task.id === "FN-LATE")?.releaseGate).toBeUndefined(); + }); + + it("drops a cached release verdict during synchronous hydration", () => { + mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED", releaseGate: { + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt: "2026-08-13T22:02:00.000Z", + } })]); + const { result } = renderHook(() => useTasks({ projectId: "proj-1", sseEnabled: false })); + + expect(result.current.tasks[0]).not.toHaveProperty("releaseGate"); + }); + + it("rejects a REST verdict evaluated for an older task row before rendering it", async () => { + mockFetchTasks.mockResolvedValueOnce([createMockTask({ + id: "FN-STALE-REST", updatedAt: "2026-08-13T22:02:01.000Z", + releaseGate: { + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt: "2026-08-13T22:02:00.000Z", + evaluatedForUpdatedAt: "2026-08-13T22:02:00.000Z", + }, + })]); + + const { result } = renderHook(() => useTasks({ sseEnabled: false })); + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + expect(result.current.tasks[0]).not.toHaveProperty("releaseGate"); + }); + it("hydrates per-project cached tasks synchronously", () => { mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED" })]); const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); @@ -309,6 +356,18 @@ describe("useTasks", () => { expect(Array.isArray(raw.data)).toBe(true); }); + it("strips a release verdict before persisting the board snapshot", async () => { + mockFetchTasks.mockResolvedValueOnce([createMockTask({ id: "FN-CACHE-GATE", releaseGate: { + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt: new Date().toISOString(), evaluatedForUpdatedAt: "2026-01-01T00:00:00Z", + } })]); + + renderHook(() => useTasks({ projectId: "proj-1", sseEnabled: false })); + await waitFor(() => expect(mockWriteCache).toHaveBeenCalled()); + const cached = mockWriteCache.mock.calls.at(-1)?.[1] as Task[]; + expect(cached[0]).not.toHaveProperty("releaseGate"); + }); + it("caps task cache writes to first 500 entries", async () => { const manyTasks = Array.from({ length: 550 }, (_, index) => createMockTask({ id: `FN-${index.toString().padStart(3, "0")}` }), @@ -821,6 +880,36 @@ describe("useTasks", () => { expect(result.current.tasks[0].id).toBe("FN-002"); }); + it("strips unproven release verdicts from created and reconnect-gap SSE upserts", async () => { + mockFetchTasks.mockResolvedValueOnce([]); + const { result } = renderHook(() => useTasks()); + + await waitFor(() => { + expect(MockEventSource.instances).toHaveLength(1); + }); + + const evaluatedAt = "2026-08-13T22:23:00.000Z"; + const withUnprovenVerdict = (id: string) => createMockTask({ + id, + updatedAt: evaluatedAt, + releaseGate: { + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt, evaluatedForUpdatedAt: evaluatedAt, + }, + }); + + act(() => { + MockEventSource.instances[0]._emit("task:created", withUnprovenVerdict("FN-SSE-CREATED")); + MockEventSource.instances[0]._emit("task:moved", { + task: withUnprovenVerdict("FN-SSE-MOVED"), from: "todo", to: "in-progress", + }); + MockEventSource.instances[0]._emit("task:updated", withUnprovenVerdict("FN-SSE-UPDATED")); + }); + + expect(result.current.tasks).toHaveLength(3); + expect(result.current.tasks.every((task) => task.releaseGate === undefined)).toBe(true); + }); + it("passes custom column ids through and normalizes structurally invalid columns from SSE created events", async () => { // FNXC:ColumnNormalization 2026-07-24-00:20: see the initial-fetch variant — post-b2a7425c7, // string column ids are custom-workflow-valid; only non-string/empty falls back to triage. @@ -1087,6 +1176,32 @@ describe("useTasks", () => { }); describe("SSE event: task:updated", () => { + it("drops a carried verdict when SSE changes visible evidence or advances the row clock", async () => { + const evaluatedAt = new Date().toISOString(); + const initial = createMockTask({ + id: "FN-RELEASE-SSE", updatedAt: evaluatedAt, status: null, + releaseGate: { + promoteBlocked: false, unplannedForExecution: false, blockedOnApproval: false, reason: null, + readyAtCapacityBoundary: false, evaluatedAt, evaluatedForUpdatedAt: evaluatedAt, + }, + }); + mockFetchTasks.mockResolvedValueOnce([initial]); + const { result } = renderHook(() => useTasks()); + await waitFor(() => expect(result.current.tasks[0]?.releaseGate).toBeDefined()); + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)); + + act(() => MockEventSource.instances[0]._emit("task:updated", { ...initial, status: "planning" })); + expect(result.current.tasks[0]?.releaseGate).toBeUndefined(); + + mockFetchTasks.mockResolvedValueOnce([initial]); + await act(async () => { await result.current.refreshTasks(); }); + expect(result.current.tasks[0]?.releaseGate).toBeDefined(); + act(() => MockEventSource.instances[0]._emit("task:updated", { + ...initial, updatedAt: "2026-12-31T00:00:00.000Z", + })); + expect(result.current.tasks[0]?.releaseGate).toBeUndefined(); + }); + it("updates task fields", async () => { const initialTask = createMockTask({ id: "FN-001", diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 8d6b7b5907..70938d73b3 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -103,6 +103,22 @@ function normalizeTask(task: Task): Task { }; } +/* +FNXC:PromoteVisibility 2026-08-13-22:23: +Only GET /api/tasks pairs a release verdict with the complete hold-lane evidence used to evaluate it. +SSE is a store-derived lifecycle channel, so remove any verdict defensively before every SSE path, +including reconnect-gap upserts; a producer regression must resolve to the conservative fallback. +*/ +function stripTransientReleaseGate(task: Task): Task { + if (!Object.prototype.hasOwnProperty.call(task, "releaseGate")) return task; + const { releaseGate: _transientReleaseGate, ...taskWithoutReleaseGate } = task; + return taskWithoutReleaseGate as Task; +} + +function normalizeNonBoardTask(task: Task): Task { + return normalizeTask(stripTransientReleaseGate(task)); +} + function isSoftDeleted(task: Task): boolean { return Boolean(task.deletedAt); } @@ -753,15 +769,23 @@ export function useTasks(options?: UseTasksOptions) { if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) { return; } - const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask)); - for (const task of normalizedFetchedTasks) { - if (task.releaseGate !== undefined) { - releaseGateProvenanceRef.current.set(task.id, { - fingerprint: releaseGateEvidenceFingerprint(task), - capturedAt: Date.now(), - }); + const fetchedAt = Date.now(); + const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask)).map((task) => { + if (task.releaseGate === undefined) return task; + const provenance = { fingerprint: releaseGateEvidenceFingerprint(task), capturedAt: fetchedAt }; + /* + FNXC:PromoteVisibility 2026-08-13-22:02: + A first-seen REST row has no current snapshot to merge against. Validate its verdict before + render too, so a response evaluated for an older row never flashes an enabled Promote action. + */ + if (!isReleaseGateVerdictFresh(task.releaseGate, task, provenance, fetchedAt)) { + releaseGateProvenanceRef.current.delete(task.id); + const { releaseGate: _staleReleaseGate, ...taskWithoutReleaseGate } = task; + return taskWithoutReleaseGate as Task; } - } + releaseGateProvenanceRef.current.set(task.id, provenance); + return task; + }); /* FNXC:ArchivePagination 2026-07-08-01:30: A generic refresh (SSE reconnect resync, tab-visibility regain, delete- @@ -973,7 +997,7 @@ export function useTasks(options?: UseTasksOptions) { const archivedTasksRef = useRef([]); const mergeArchivedPage = useCallback((page: Task[]) => { - const normalizedPage = page.map(normalizeTask); + const normalizedPage = page.map(normalizeNonBoardTask); const knownArchivedIds = new Set(archivedTasksRef.current.map((task) => task.id)); const newArchived = normalizedPage.filter((task) => !knownArchivedIds.has(task.id)); if (newArchived.length > 0) { @@ -1045,7 +1069,7 @@ export function useTasks(options?: UseTasksOptions) { loggedTaskCacheHitProjects.add(projectId); console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId); } - setTasks(filterActiveTasks(cachedTasks.map(normalizeTask))); + setTasks(filterActiveTasks(cachedTasks.map(normalizeNonBoardTask))); /* FNXC:MobileTabDiscard 2026-07-26-14:18: A project switch replaces `tasks` with the new project's snapshot, so the freshness clock must @@ -1182,7 +1206,7 @@ export function useTasks(options?: UseTasksOptions) { traceDroppedStaleEvent(); return; } - const task = normalizeTask(JSON.parse(e.data) as Task); + const task = normalizeTask(stripTransientReleaseGate(JSON.parse(e.data) as Task)); recordLiveMutation(task, isSoftDeleted(task)); if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); @@ -1223,7 +1247,7 @@ export function useTasks(options?: UseTasksOptions) { } // #1403: the move event carries `ColumnId` (custom column ids admitted). const { task, to }: { task: Task; from: ColumnId; to: ColumnId } = JSON.parse(e.data); - const normalizedTask = normalizeTask(task); + const normalizedTask = normalizeTask(stripTransientReleaseGate(task)); if (isSoftDeleted(normalizedTask)) { recordLiveMutation(normalizedTask, true); applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); @@ -1264,7 +1288,7 @@ export function useTasks(options?: UseTasksOptions) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } - const incoming = normalizeTask(JSON.parse(e.data) as Task); + const incoming = normalizeTask(stripTransientReleaseGate(JSON.parse(e.data) as Task)); recordLiveMutation(incoming, isSoftDeleted(incoming)); if (isSoftDeleted(incoming)) { // FN-5135: treat deletedAt-bearing task:updated payloads as delete-equivalent. @@ -1296,7 +1320,7 @@ export function useTasks(options?: UseTasksOptions) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } - const task = normalizeTask(JSON.parse(e.data) as Task); + const task = normalizeTask(stripTransientReleaseGate(JSON.parse(e.data) as Task)); recordLiveMutation(task, true); applyLiveTasks((prev) => prev.filter((t) => t.id !== task.id)); }; @@ -1311,7 +1335,7 @@ export function useTasks(options?: UseTasksOptions) { return; } const { task }: { task: Task } = JSON.parse(e.data); - const normalizedTask = normalizeTask(task); + const normalizedTask = normalizeTask(stripTransientReleaseGate(task)); if (isSoftDeleted(normalizedTask)) { recordLiveMutation(normalizedTask, true); applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); @@ -1381,7 +1405,7 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId, sseEnabled, revalidateAfterResume]); const createTask = useCallback(async (input: TaskCreateInput): Promise => { - const task = normalizeTask(await api.createTask(input, projectId)); + const task = normalizeNonBoardTask(await api.createTask(input, projectId)); setTasks((prev) => { if (prev.some((t) => t.id === task.id)) return prev; return [...prev, task]; @@ -1394,7 +1418,7 @@ export function useTasks(options?: UseTasksOptions) { column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number, ): Promise => { - return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition)); + return normalizeNonBoardTask(await api.moveTask(id, column, projectId, optionsOrPosition)); }, [projectId]); /* @@ -1405,7 +1429,7 @@ export function useTasks(options?: UseTasksOptions) { hosts cannot diverge after pause or unpause. */ const reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => { - const normalizedConfirmedRow = normalizeTask(confirmedTask); + const normalizedConfirmedRow = normalizeNonBoardTask(confirmedTask); // Preserve cleared lifecycle fields as own `undefined` properties so every downstream // snapshot host can distinguish the confirmed deletion from an unrelated sparse update. const confirmedRow: Task = { @@ -1471,7 +1495,7 @@ export function useTasks(options?: UseTasksOptions) { allowResurrection?: boolean; }, ): Promise => { - const deletedTask = normalizeTask(await api.deleteTask(id, projectId, options)); + const deletedTask = normalizeNonBoardTask(await api.deleteTask(id, projectId, options)); /* FNXC:TaskDeletion 2026-06-29-18:52: Local deletes must update the shared useTasks array immediately because the Board and right-dock Tasks list both render from this state and should not wait for SSE or a refetch after the API confirms deletion. @@ -1510,7 +1534,7 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); const retryTask = useCallback(async (id: string): Promise => { - const retriedTask = normalizeTask(await api.retryTask(id, projectId)); + const retriedTask = normalizeNonBoardTask(await api.retryTask(id, projectId)); /* FNXC:DashboardTaskRetry 2026-06-30-12:57: Manual retry success is a user-visible state boundary. Replace matching rows in shared hook state and the project SWR cache as soon as the retry API returns so Board/List/detail/right-dock retry affordances do not depend on later SSE, polling, remount, or route re-entry to clear stale failed/stuck state. @@ -1556,7 +1580,7 @@ export function useTasks(options?: UseTasksOptions) { failed-step indicator after the operator receives server confirmation. */ const bypassReview = useCallback(async (id: string, reason: string): Promise => { - const bypassedTask = normalizeTask(await api.bypassReview(id, reason, projectId)); + const bypassedTask = normalizeNonBoardTask(await api.bypassReview(id, reason, projectId)); fetchVersionRef.current++; const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? bypassedTask : task)); @@ -1589,11 +1613,11 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); const resetTask = useCallback(async (id: string): Promise => { - return normalizeTask(await api.resetTask(id, projectId)); + return normalizeNonBoardTask(await api.resetTask(id, projectId)); }, [projectId]); const duplicateTask = useCallback(async (id: string): Promise => { - const task = normalizeTask(await api.duplicateTask(id, projectId)); + const task = normalizeNonBoardTask(await api.duplicateTask(id, projectId)); setTasks((prev) => { if (prev.some((t) => t.id === task.id)) return prev; return [...prev, task]; @@ -1617,7 +1641,7 @@ export function useTasks(options?: UseTasksOptions) { } try { - const updatedTask = normalizeTask(await api.updateTask(id, updates, projectId)); + const updatedTask = normalizeNonBoardTask(await api.updateTask(id, updates, projectId)); setTasks((prev) => prev.map((t) => (t.id === id ? updatedTask : t)) ); @@ -1636,7 +1660,7 @@ export function useTasks(options?: UseTasksOptions) { id: string, options?: { removeLineageReferences?: boolean }, ): Promise => { - const task = normalizeTask(await api.archiveTask(id, projectId, options)); + const task = normalizeNonBoardTask(await api.archiveTask(id, projectId, options)); setTasks((prev) => prev.map((t) => (t.id === id ? task : t)) ); @@ -1644,7 +1668,7 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); const unarchiveTask = useCallback(async (id: string): Promise => { - const task = normalizeTask(await api.unarchiveTask(id, projectId)); + const task = normalizeNonBoardTask(await api.unarchiveTask(id, projectId)); setTasks((prev) => prev.map((t) => (t.id === id ? task : t)) ); @@ -1672,7 +1696,7 @@ export function useTasks(options?: UseTasksOptions) { const archiveAllDone = useCallback(async (): Promise => { const archived = await api.archiveAllDone(projectId); - const normalized = archived.map(normalizeTask); + const normalized = archived.map(normalizeNonBoardTask); setTasks((prev) => prev.map((t) => { const updated = normalized.find((archived) => archived.id === t.id); @@ -1692,7 +1716,7 @@ export function useTasks(options?: UseTasksOptions) { return; } - const normalizedTasks = filterActiveTasks(incomingTasks.map(normalizeTask)); + const normalizedTasks = filterActiveTasks(incomingTasks.map(normalizeNonBoardTask)); setTasks((prev) => { let next = prev; diff --git a/packages/dashboard/app/utils/__tests__/releaseGate.test.ts b/packages/dashboard/app/utils/__tests__/releaseGate.test.ts index 5fb31f36eb..d1656c2978 100644 --- a/packages/dashboard/app/utils/__tests__/releaseGate.test.ts +++ b/packages/dashboard/app/utils/__tests__/releaseGate.test.ts @@ -44,6 +44,15 @@ describe("release-gate freshness", () => { expect(isReleaseGateVerdictFresh(verdict, task, provenance, now)).toBe(true); expect(isReleaseGateVerdictFresh(verdict, { ...task, status: "planning" }, provenance, now)).toBe(false); expect(isReleaseGateVerdictFresh(verdict, { ...task, updatedAt: "2026-08-11T20:00:00.001Z" }, provenance, now)).toBe(false); + expect(isReleaseGateVerdictFresh(verdict, task, provenance, now + RELEASE_GATE_VERDICT_MAX_AGE_MS)).toBe(true); expect(isReleaseGateVerdictFresh(verdict, task, provenance, now + RELEASE_GATE_VERDICT_MAX_AGE_MS + 1)).toBe(false); }); + + it("requires local provenance, a parseable timestamp, and the evaluated row clock", () => { + const provenance = { fingerprint: releaseGateEvidenceFingerprint(task), capturedAt: 0 }; + const now = Date.parse(verdict.evaluatedAt); + expect(isReleaseGateVerdictFresh(verdict, task, undefined, now)).toBe(false); + expect(isReleaseGateVerdictFresh({ ...verdict, evaluatedAt: "not-a-date" }, task, provenance, now)).toBe(false); + expect(isReleaseGateVerdictFresh({ ...verdict, evaluatedForUpdatedAt: undefined }, task, provenance, now)).toBe(false); + }); }); diff --git a/packages/dashboard/src/__tests__/routes-tasks-release-gate-transient.test.ts b/packages/dashboard/src/__tests__/routes-tasks-release-gate-transient.test.ts new file mode 100644 index 0000000000..71b5075953 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-tasks-release-gate-transient.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import * as core from "@fusion/core"; +import * as engine from "@fusion/engine"; +import type { Task, TaskStore, WorkflowIr } from "@fusion/core"; +import { request as performRequest } from "../test-request.js"; +import { ApiError, sendErrorResponse } from "../api-error.js"; +import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js"; + +const verdict = { + promoteBlocked: false, + unplannedForExecution: false, + blockedOnApproval: false, + reason: null, + readyAtCapacityBoundary: true, + evaluatedAt: "2026-08-13T22:02:00.000Z", + evaluatedForUpdatedAt: "2026-08-13T22:02:00.000Z", +} as const; + +function createTask(id: string): Task { + return { + id, + description: "Release verdict must stay transient", + column: "todo", + dependencies: [], + createdAt: "2026-08-13T22:02:00.000Z", + updatedAt: "2026-08-13T22:02:00.000Z", + steps: [], + log: [], + } as Task; +} + +function buildApp(tasks: Task[]) { + const store: Partial = { + listTasks: vi.fn().mockResolvedValue(tasks), + getBranchProgressByTask: vi.fn().mockResolvedValue(new Map()), + getSettingsFast: vi.fn().mockResolvedValue({}), + getTaskDir: vi.fn().mockReturnValue("/missing-task-dir"), + }; + const router = express.Router(); + const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + registerTaskWorkflowRoutes({ + router, + store: store as TaskStore, + options: {}, + runtimeLogger: logger as never, + planningLogger: logger as never, + chatLogger: logger as never, + getProjectIdFromRequest: () => undefined, + getScopedStore: async () => store as TaskStore, + getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: undefined }), + prioritizeProjectsForCurrentDirectory: (projects) => projects, + emitRemoteRouteDiagnostic: () => {}, emitAuthSyncAuditLog: () => {}, parseScopeParam: () => undefined, + resolveAutomationStore: () => ({}) as never, resolveRoutineStore: () => ({}) as never, + resolveRoutineRunner: () => ({}) as never, registerDispose: () => {}, dispose: () => {}, + rethrowAsApiError: (error: unknown): never => { throw error instanceof ApiError ? error : new ApiError(500, String(error)); }, + }, { + runtimeLogger: logger as never, + upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() }, + taskDetailActivityLogLimit: 100, validateOptionalModelField: () => undefined, + normalizeModelSelectionPair: () => ({ provider: null, modelId: null }), runGitCommand: async () => "", + trimTaskDetailActivityLog: (task) => task, triggerCommentWakeForAssignedAgent: async () => {}, + }); + const app = express(); + app.use("/api", router); + app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => + sendErrorResponse(res, error instanceof ApiError ? error.statusCode : 500, error instanceof Error ? error.message : String(error)), + ); + return { app, store }; +} + +describe("GET /api/tasks releaseGate enrichment", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns a verdict only in the response, never mutating the stored task", async () => { + const task = createTask("FN-9029"); + vi.spyOn(core, "resolveProjectColumnsForRoles").mockResolvedValue(new Set(["todo"])); + vi.spyOn(core, "resolveWorkflowIrForTask").mockResolvedValue({ version: "v2" } as WorkflowIr); + vi.spyOn(engine, "evaluateTaskReleaseGate").mockResolvedValue(verdict); + const { app, store } = buildApp([task]); + + const response = await performRequest(app, "GET", "/api/tasks"); + + expect(response.status).toBe(200); + expect((response.body as Task[])[0]).toMatchObject({ id: task.id, releaseGate: verdict }); + expect(task).not.toHaveProperty("releaseGate"); + expect(await store.listTasks?.({ slim: true })).not.toEqual(expect.arrayContaining([expect.objectContaining({ releaseGate: expect.anything() })])); + }); + + it("keeps verdict-less and truncation rows byte-identical", async () => { + const first = createTask("FN-9029-FIRST"); + const beyondLimit = createTask("FN-9029-BEYOND"); + vi.spyOn(core, "resolveProjectColumnsForRoles").mockResolvedValue(new Set()); + const { app } = buildApp([first, beyondLimit]); + + const response = await performRequest(app, "GET", "/api/tasks"); + + expect(response.status).toBe(200); + expect(response.body).toEqual([first, beyondLimit]); + }); +}); diff --git a/packages/dashboard/src/__tests__/sse-task-deleted-payload.test.ts b/packages/dashboard/src/__tests__/sse-task-deleted-payload.test.ts index 41ec9596a0..8bc2d9fc0f 100644 --- a/packages/dashboard/src/__tests__/sse-task-deleted-payload.test.ts +++ b/packages/dashboard/src/__tests__/sse-task-deleted-payload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stripTaskListHeavyFields } from "../sse"; +import { stripTaskEventHeavyFields, stripTaskListHeavyFields } from "../sse"; describe("stripTaskListHeavyFields", () => { it("preserves deletedAt on slim SSE payloads", () => { @@ -14,4 +14,12 @@ describe("stripTaskListHeavyFields", () => { expect(slimmed.deletedAt).toBe("2026-05-19T00:00:00.000Z"); }); + + it("never broadcasts a transient release verdict in created, updated, or moved payload shapes", () => { + const releaseGate = { promoteBlocked: false, evaluatedAt: "2026-08-13T22:02:00.000Z" }; + const task = { id: "FN-9029", log: [], releaseGate }; + + expect(stripTaskListHeavyFields(task)).not.toHaveProperty("releaseGate"); + expect(stripTaskEventHeavyFields({ task, from: "todo", to: "in-progress" }).task).not.toHaveProperty("releaseGate"); + }); }); diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index f2858a249d..0e603033b3 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -196,12 +196,20 @@ export function stripTaskListHeavyFields(task: T): T { return task; } - if (!("log" in task)) { - return task; + const candidate = task as Record; + /* + FNXC:PromoteVisibility 2026-08-13-22:02: + Release-gate verdicts are response-only GET /api/tasks enrichments. Strip one defensively at the + SSE boundary so a future event producer cannot persist a stale Promote approval in browser state. + */ + const { releaseGate: _transientReleaseGate, ...taskWithoutReleaseGate } = candidate; + + if (!("log" in taskWithoutReleaseGate)) { + return taskWithoutReleaseGate as T; } - const candidate = task as Record; - const existingTimed = candidate.timedExecutionMs; + const taskCandidate = taskWithoutReleaseGate as Record; + const existingTimed = taskCandidate.timedExecutionMs; // Mirror the slim REST path (listTasks): aggregate `[timing] … in ms` // log entries before stripping the log so the board card has the same // total-execution figure on SSE updates as on the initial fetch. @@ -211,15 +219,15 @@ export function stripTaskListHeavyFields(task: T): T { const timedExecutionMs = typeof existingTimed === "number" ? existingTimed - : sumTimedLogEntries(candidate.log); + : sumTimedLogEntries(taskCandidate.log); return { - ...task, + ...taskWithoutReleaseGate, // FN-5105/FN-5135: preserve deletedAt in SSE slim payloads for soft-delete suppression. log: [], timedExecutionMs, - tokenUsage: candidate.tokenUsage, - workflowStepResults: candidate.workflowStepResults, + tokenUsage: taskCandidate.tokenUsage, + workflowStepResults: taskCandidate.workflowStepResults, } as T; } @@ -243,7 +251,7 @@ function sumTimedLogEntries(log: unknown): number { return total; } -function stripTaskEventHeavyFields(payload: T): T { +export function stripTaskEventHeavyFields(payload: T): T { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return payload; }