fix(dashboard): return authoritative archived state and cover ai-session archive
Archive/unarchive routes were returning the row-update boolean rather than the actual archived flag, so a no-op unarchive replied with `archived: true`. Re-read the row and report its current state instead. Also adds tests for archive (terminal-only), unarchive event emission, and listAll filtering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -736,7 +736,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setMobileShowDetail(false);
|
setMobileShowDetail(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[planningSessions, resetDetailState, selectedSessionId, showArchived],
|
[planningSessions, resetDetailState, selectedSessionId, setMobileShowDetail, showArchived],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset hasAutoStarted when modal closes
|
// Reset hasAutoStarted when modal closes
|
||||||
|
|||||||
@@ -319,6 +319,81 @@ describe("AiSessionStore", () => {
|
|||||||
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("archive / unarchive / listAll", () => {
|
||||||
|
it("archive only flips terminal sessions and emits updated", () => {
|
||||||
|
seedSession({ id: "S-complete", status: "complete" });
|
||||||
|
seedSession({ id: "S-error", status: "error" });
|
||||||
|
seedSession({ id: "S-generating", status: "generating" });
|
||||||
|
seedSession({ id: "S-awaiting", status: "awaiting_input" });
|
||||||
|
|
||||||
|
const updated: string[] = [];
|
||||||
|
store.on("ai_session:updated", (summary) => updated.push(summary.id));
|
||||||
|
|
||||||
|
expect(store.archive("S-complete")).toBe(true);
|
||||||
|
expect(store.archive("S-error")).toBe(true);
|
||||||
|
expect(store.archive("S-generating")).toBe(false);
|
||||||
|
expect(store.archive("S-awaiting")).toBe(false);
|
||||||
|
expect(store.archive("missing")).toBe(false);
|
||||||
|
|
||||||
|
expect(store.get("S-complete")?.archived).toBe(1);
|
||||||
|
expect(store.get("S-error")?.archived).toBe(1);
|
||||||
|
expect(store.get("S-generating")?.archived ?? 0).toBe(0);
|
||||||
|
expect(updated.sort()).toEqual(["S-complete", "S-error"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unarchive flips archived sessions back and emits updated only when changed", () => {
|
||||||
|
seedSession({ id: "S-complete", status: "complete" });
|
||||||
|
store.archive("S-complete");
|
||||||
|
|
||||||
|
const updated: string[] = [];
|
||||||
|
store.on("ai_session:updated", (summary) => updated.push(summary.id));
|
||||||
|
|
||||||
|
expect(store.unarchive("S-complete")).toBe(true);
|
||||||
|
expect(store.get("S-complete")?.archived ?? 0).toBe(0);
|
||||||
|
expect(updated).toEqual(["S-complete"]);
|
||||||
|
|
||||||
|
// No-op unarchive of an already-unarchived row still updates the row
|
||||||
|
// (touches updatedAt) — this test pins the current behavior so the
|
||||||
|
// route handler can rely on `get()` for the authoritative state.
|
||||||
|
updated.length = 0;
|
||||||
|
expect(store.unarchive("S-complete")).toBe(true);
|
||||||
|
expect(store.get("S-complete")?.archived ?? 0).toBe(0);
|
||||||
|
|
||||||
|
expect(store.unarchive("missing")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listAll excludes archived rows by default and includes them when requested", () => {
|
||||||
|
seedSession({ id: "S-active", status: "generating" });
|
||||||
|
seedSession({ id: "S-done-visible", status: "complete" });
|
||||||
|
seedSession({ id: "S-done-hidden", status: "complete" });
|
||||||
|
store.archive("S-done-hidden");
|
||||||
|
|
||||||
|
const visible = store.listAll();
|
||||||
|
expect(visible.map((s) => s.id).sort()).toEqual(["S-active", "S-done-visible"]);
|
||||||
|
|
||||||
|
const all = store.listAll(undefined, { includeArchived: true });
|
||||||
|
expect(all.map((s) => s.id).sort()).toEqual(["S-active", "S-done-hidden", "S-done-visible"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listAll filters by projectId with and without archived", () => {
|
||||||
|
seedSession({ id: "S-a-active", status: "generating", projectId: "project-a" });
|
||||||
|
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
|
||||||
|
seedSession({ id: "S-a-archived", status: "complete", projectId: "project-a" });
|
||||||
|
seedSession({ id: "S-b-done", status: "complete", projectId: "project-b" });
|
||||||
|
store.archive("S-a-archived");
|
||||||
|
|
||||||
|
const projectA = store.listAll("project-a");
|
||||||
|
expect(projectA.map((s) => s.id).sort()).toEqual(["S-a-active", "S-a-done"]);
|
||||||
|
|
||||||
|
const projectAWithArchived = store.listAll("project-a", { includeArchived: true });
|
||||||
|
expect(projectAWithArchived.map((s) => s.id).sort()).toEqual([
|
||||||
|
"S-a-active",
|
||||||
|
"S-a-archived",
|
||||||
|
"S-a-done",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("ping updates updatedAt for existing sessions without emitting updates", () => {
|
it("ping updates updatedAt for existing sessions without emitting updates", () => {
|
||||||
seedSession({ id: "S-ping", status: "awaiting_input" });
|
seedSession({ id: "S-ping", status: "awaiting_input" });
|
||||||
|
|
||||||
|
|||||||
@@ -3455,8 +3455,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
if (session.status !== "complete" && session.status !== "error") {
|
if (session.status !== "complete" && session.status !== "error") {
|
||||||
throw badRequest("Only completed or errored sessions can be archived");
|
throw badRequest("Only completed or errored sessions can be archived");
|
||||||
}
|
}
|
||||||
const ok = aiSessionStore.archive(req.params.id);
|
aiSessionStore.archive(req.params.id);
|
||||||
res.json({ archived: ok });
|
const after = aiSessionStore.get(req.params.id);
|
||||||
|
res.json({ archived: Number(after?.archived ?? 0) === 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3470,8 +3471,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
if (!aiSessionStore.get(req.params.id)) {
|
if (!aiSessionStore.get(req.params.id)) {
|
||||||
throw notFound("Session not found");
|
throw notFound("Session not found");
|
||||||
}
|
}
|
||||||
const ok = aiSessionStore.unarchive(req.params.id);
|
aiSessionStore.unarchive(req.params.id);
|
||||||
res.json({ archived: !ok });
|
const after = aiSessionStore.get(req.params.id);
|
||||||
|
res.json({ archived: Number(after?.archived ?? 0) === 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/ai-sessions/:id/lock", (req, res) => {
|
router.post("/ai-sessions/:id/lock", (req, res) => {
|
||||||
@@ -4007,7 +4009,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
|
|
||||||
// Scripts and messaging routes are registered by registerMessagingScriptRoutes().
|
// Scripts and messaging routes are registered by registerMessagingScriptRoutes().
|
||||||
|
|
||||||
router.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
|
router.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
next(err);
|
next(err);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user