FN-9029: keep release-gate verdicts transient

Keep release-gate verdicts restricted to fresh task-list responses.

- Strip release-gate data from SSE and non-board task paths.
- Reject stale initial verdicts before rendering promote controls.
- Cover server persistence, SSE payload, and client freshness boundaries.

Files changed:
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 115 +++++++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           |  80 +++++++++-----
 .../app/utils/__tests__/releaseGate.test.ts        |   9 ++
 .../routes-tasks-release-gate-transient.test.ts    | 103 ++++++++++++++++++
 .../src/__tests__/sse-task-deleted-payload.test.ts |  10 +-
 packages/dashboard/src/sse.ts                      |  26 +++--
 6 files changed, 305 insertions(+), 38 deletions(-)

Fusion-Task-Id: FN-9029

Fusion-Task-Lineage: cf764f3e-c6f5-4fa1-b608-ebb8d547cfdd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 15:43:16 -07:00
parent f88031172e
commit f6518b4275
6 changed files with 305 additions and 38 deletions

View File

@@ -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",

View File

@@ -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<Task[]>([]);
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<Task> => {
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<Task> => {
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<Task> => {
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<Task> => {
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<Task> => {
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<Task> => {
return normalizeTask(await api.resetTask(id, projectId));
return normalizeNonBoardTask(await api.resetTask(id, projectId));
}, [projectId]);
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
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<Task> => {
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<Task> => {
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<Task[]> => {
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;

View File

@@ -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);
});
});

View File

@@ -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<TaskStore> = {
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]);
});
});

View File

@@ -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");
});
});

View File

@@ -196,12 +196,20 @@ export function stripTaskListHeavyFields<T>(task: T): T {
return task;
}
if (!("log" in task)) {
return task;
const candidate = task as Record<string, unknown>;
/*
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<string, unknown>;
const existingTimed = candidate.timedExecutionMs;
const taskCandidate = taskWithoutReleaseGate as Record<string, unknown>;
const existingTimed = taskCandidate.timedExecutionMs;
// Mirror the slim REST path (listTasks): aggregate `[timing] … in <N>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<T>(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<T>(payload: T): T {
export function stripTaskEventHeavyFields<T>(payload: T): T {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return payload;
}