feat(FN-2362): merge fusion/fn-2362

This commit is contained in:
gsxdsm
2026-04-23 14:10:57 -07:00
parent 1d7c32b565
commit 77a1d5fb92
2 changed files with 106 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ import {
type AiSessionRow, type AiSessionRow,
type AiSessionStatus, type AiSessionStatus,
} from "./ai-session-store.js"; } from "./ai-session-store.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "./ai-session-diagnostics.js";
describe("AiSessionStore", () => { describe("AiSessionStore", () => {
let tmpRoot: string; let tmpRoot: string;
@@ -25,6 +26,7 @@ describe("AiSessionStore", () => {
afterEach(async () => { afterEach(async () => {
store.stopScheduledCleanup(); store.stopScheduledCleanup();
resetDiagnosticsSink();
vi.useRealTimers(); vi.useRealTimers();
try { try {
db.close(); db.close();
@@ -73,6 +75,20 @@ describe("AiSessionStore", () => {
} }
} }
function captureDiagnostics(): LogEntry[] {
const entries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
entries.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
});
return entries;
}
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => { it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
const deletedIds: string[] = []; const deletedIds: string[] = [];
store.on("ai_session:deleted", (id) => deletedIds.push(id)); store.on("ai_session:deleted", (id) => deletedIds.push(id));
@@ -113,6 +129,35 @@ describe("AiSessionStore", () => {
expect(store.get("S-generating-fresh")).not.toBeNull(); expect(store.get("S-generating-fresh")).not.toBeNull();
}); });
it("cleanupStaleSessions emits structured diagnostics with cleanup summary counts", () => {
const diagnostics = captureDiagnostics();
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
const summary = store.cleanupStaleSessions();
expect(summary).toEqual({
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
});
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "info",
scope: "ai-session-store",
message: "Cleanup removed stale sessions",
context: expect.objectContaining({
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
}),
}),
);
});
it("cleanupStaleSessions respects explicit maxAgeMs values", () => { it("cleanupStaleSessions respects explicit maxAgeMs values", () => {
seedSession({ id: "S-complete-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 }); seedSession({ id: "S-complete-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting-older", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 }); seedSession({ id: "S-awaiting-older", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
@@ -162,6 +207,33 @@ describe("AiSessionStore", () => {
expect(store.get("S-old-2")).not.toBeNull(); expect(store.get("S-old-2")).not.toBeNull();
}); });
it("startScheduledCleanup emits structured error diagnostics and remains non-fatal on cleanup failure", () => {
vi.useFakeTimers();
const diagnostics = captureDiagnostics();
const cleanupSpy = vi
.spyOn(store, "cleanupStaleSessions")
.mockImplementation(() => {
throw new Error("boom");
});
store.startScheduledCleanup(1_000, 60_000);
expect(() => vi.advanceTimersByTime(2_000)).not.toThrow();
expect(cleanupSpy).toHaveBeenCalledTimes(2);
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "ai-session-store",
message: "Scheduled cleanup failed",
context: expect.objectContaining({
ttlMs: 60_000,
error: expect.objectContaining({ message: "boom" }),
}),
}),
);
});
it("supports configurable TTL values", () => { it("supports configurable TTL values", () => {
seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 }); seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 }); seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 });
@@ -192,6 +264,29 @@ describe("AiSessionStore", () => {
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart"); expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
}); });
it("recoverStaleSessions emits structured diagnostics when stale sessions are recovered", () => {
const diagnostics = captureDiagnostics();
seedSession({
id: "S-recoverable",
status: "generating",
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
});
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
const recovered = store.recoverStaleSessions();
expect(recovered).toBe(2);
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "info",
scope: "ai-session-store",
message: "Recovered stale sessions after restart",
context: expect.objectContaining({ recovered: 2 }),
}),
);
});
it("listActive returns generating/awaiting_input/error sessions", () => { it("listActive returns generating/awaiting_input/error sessions", () => {
seedSession({ id: "S-generating", status: "generating" }); seedSession({ id: "S-generating", status: "generating" });
seedSession({ id: "S-awaiting", status: "awaiting_input" }); seedSession({ id: "S-awaiting", status: "awaiting_input" });

View File

@@ -12,6 +12,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { Database } from "@fusion/core"; import type { Database } from "@fusion/core";
import { createSessionDiagnostics } from "./ai-session-diagnostics.js";
// ── Types ─────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────
@@ -72,6 +73,8 @@ export interface AiSessionCleanupSummary {
totalDeleted: number; totalDeleted: number;
} }
const diagnostics = createSessionDiagnostics("ai-session-store");
// ── Store ─────────────────────────────────────────────────────────────── // ── Store ───────────────────────────────────────────────────────────────
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> { export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
@@ -403,7 +406,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
recovered += Number(withoutQuestion.changes ?? 0); recovered += Number(withoutQuestion.changes ?? 0);
if (recovered > 0) { if (recovered > 0) {
console.log(`[ai-session-store] Recovered ${recovered} stale sessions after restart`); diagnostics.info("Recovered stale sessions after restart", { recovered });
} }
return recovered; return recovered;
} }
@@ -471,9 +474,11 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
} }
const totalDeleted = terminalDeleted + orphanedDeleted; const totalDeleted = terminalDeleted + orphanedDeleted;
console.log( diagnostics.info("Cleanup removed stale sessions", {
`[ai-session-store] Cleanup: removed ${terminalDeleted} terminal, ${orphanedDeleted} orphaned sessions`, terminalDeleted,
); orphanedDeleted,
totalDeleted,
});
return { return {
terminalDeleted, terminalDeleted,
@@ -491,8 +496,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
const runCleanup = () => { const runCleanup = () => {
try { try {
this.cleanupStaleSessions(ttlMs); this.cleanupStaleSessions(ttlMs);
} catch (err) { } catch (error) {
console.error("[ai-session-store] Scheduled cleanup failed:", err); diagnostics.errorFromException("Scheduled cleanup failed", error, { ttlMs });
} }
}; };