feat(FN-2362): merge fusion/fn-2362
This commit is contained in:
@@ -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" });
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user