Extends the inMemoryDb opt-in established in 4fc58e0bb to single-instance
TaskStore/AgentStore/RoutineStore/PluginStore tests that were still
opening disk-backed fusion.db files. Cross-instance persistence tests
(open store A, close, open store B on same dir) and migration tests that
seed via a sibling Database instance keep their disk-backed stores —
swapping those would silently drop data between instances.
Sites flipped:
- core: store.test.ts (RunMutationContext, memory-toggle, diagnostics
blocks), store-sort, settings-export, backup, plugin-loader,
agent-instructions, agent-instructions-bundle, mission-store (all 10
triage subtests), mission-planning-context.integration
- dashboard: routes.test.ts (Messaging routes block),
session-reconnect, session-cross-tab, planning
Sites left disk-backed (cross-instance or sibling-Database dependency):
- run-audit*, task-documents, fts5-guard (sibling Database at same dir)
- mission-integration, mission-factory-parity (taskStore2 reopens)
- agent-store checkout-leasing (TaskStore + AgentStore at same dir)
- routes.test.ts AgentStore seed pattern (route handler opens its own)
- cli/extension.test.ts (makeCtx opens its own TaskStore)
All disk-backed tests continue to write to mkdtemp temp dirs — no live-db
risk introduced. Wall-clock impact: core 149s → 19s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
/**
|
|
* Covers optimistic locking and cross-tab continuity primitives:
|
|
* lock conflicts, beacon release, stale lock expiry, SSE summaries, and stale cleanup.
|
|
*/
|
|
|
|
// @vitest-environment node
|
|
|
|
import express from "express";
|
|
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { TaskStore } from "@fusion/core";
|
|
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
|
|
import { createApiRoutes } from "../routes.js";
|
|
import { request } from "../test-request.js";
|
|
|
|
function makeRow(id: string, overrides: Partial<AiSessionRow> = {}): AiSessionRow {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id,
|
|
type: "planning",
|
|
status: "awaiting_input",
|
|
title: `Session ${id}`,
|
|
inputPayload: JSON.stringify({ initialPlan: "Cross-tab lock test" }),
|
|
conversationHistory: "[]",
|
|
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Q" }),
|
|
result: null,
|
|
thinkingOutput: "",
|
|
error: null,
|
|
projectId: "proj-locks",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lockedByTab: null,
|
|
lockedAt: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("cross-tab session locking", () => {
|
|
let tmpRoot: string;
|
|
let taskStore: TaskStore;
|
|
let aiSessionStore: AiSessionStore;
|
|
let app: express.Express;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-cross-tab-"));
|
|
taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
|
|
await taskStore.init();
|
|
aiSessionStore = new AiSessionStore(taskStore.getDatabase());
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use("/api", createApiRoutes(taskStore, { aiSessionStore }));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
taskStore.close();
|
|
} catch {
|
|
// no-op
|
|
}
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("enforces optimistic lock conflicts and reports current holder", () => {
|
|
aiSessionStore.upsert(makeRow("lock-conflict"));
|
|
|
|
const first = aiSessionStore.acquireLock("lock-conflict", "tab-a");
|
|
const second = aiSessionStore.acquireLock("lock-conflict", "tab-b");
|
|
|
|
expect(first).toEqual({ acquired: true, currentHolder: null });
|
|
expect(second).toEqual({ acquired: false, currentHolder: "tab-a" });
|
|
expect(aiSessionStore.getLockHolder("lock-conflict").tabId).toBe("tab-a");
|
|
});
|
|
|
|
it("supports concurrent lock attempts where only one tab acquires", async () => {
|
|
aiSessionStore.upsert(makeRow("lock-race"));
|
|
|
|
const [resultA, resultB] = await Promise.all([
|
|
Promise.resolve(aiSessionStore.acquireLock("lock-race", "tab-a")),
|
|
Promise.resolve(aiSessionStore.acquireLock("lock-race", "tab-b")),
|
|
]);
|
|
|
|
const acquiredCount = [resultA, resultB].filter((result) => result.acquired).length;
|
|
const denied = [resultA, resultB].find((result) => !result.acquired);
|
|
|
|
expect(acquiredCount).toBe(1);
|
|
expect(denied?.currentHolder).toMatch(/^tab-[ab]$/);
|
|
});
|
|
|
|
it("releases lock on tab close beacon endpoint", async () => {
|
|
aiSessionStore.upsert(makeRow("lock-beacon"));
|
|
aiSessionStore.acquireLock("lock-beacon", "tab-a");
|
|
|
|
const response = await request(
|
|
app,
|
|
"DELETE",
|
|
"/api/ai-sessions/lock-beacon/lock/beacon?tabId=tab-a",
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(aiSessionStore.getLockHolder("lock-beacon")).toEqual({ tabId: null, lockedAt: null });
|
|
});
|
|
|
|
it("expires stale locks and clears ownership", () => {
|
|
aiSessionStore.upsert(makeRow("lock-expiry"));
|
|
aiSessionStore.acquireLock("lock-expiry", "tab-expired");
|
|
|
|
const staleTimestamp = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
|
taskStore
|
|
.getDatabase()
|
|
.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?")
|
|
.run(staleTimestamp, "lock-expiry");
|
|
|
|
const released = aiSessionStore.releaseStaleLocks(30 * 60 * 1000);
|
|
|
|
expect(released).toBe(1);
|
|
expect(aiSessionStore.getLockHolder("lock-expiry")).toEqual({ tabId: null, lockedAt: null });
|
|
});
|
|
|
|
it("emits ai_session:updated summaries on lock acquisition/release transitions", () => {
|
|
aiSessionStore.upsert(makeRow("lock-sse"));
|
|
const summaries: Array<{ id: string; lockedByTab: string | null }> = [];
|
|
|
|
aiSessionStore.on("ai_session:updated", (summary) => {
|
|
summaries.push({ id: summary.id, lockedByTab: summary.lockedByTab });
|
|
});
|
|
|
|
aiSessionStore.acquireLock("lock-sse", "tab-a");
|
|
aiSessionStore.releaseLock("lock-sse", "tab-a");
|
|
aiSessionStore.forceAcquireLock("lock-sse", "tab-b");
|
|
|
|
expect(summaries).toEqual(
|
|
expect.arrayContaining([
|
|
{ id: "lock-sse", lockedByTab: "tab-a" },
|
|
{ id: "lock-sse", lockedByTab: null },
|
|
{ id: "lock-sse", lockedByTab: "tab-b" },
|
|
]),
|
|
);
|
|
|
|
const listActive = aiSessionStore.listActive("proj-locks");
|
|
const latest = listActive.find((session) => session.id === "lock-sse");
|
|
expect(latest?.lockedByTab).toBe("tab-b");
|
|
});
|
|
|
|
it("cleans stale active sessions and leaves fresh sessions intact", () => {
|
|
aiSessionStore.upsert(makeRow("stale-generating", { status: "generating" }));
|
|
aiSessionStore.upsert(makeRow("stale-awaiting", { status: "awaiting_input" }));
|
|
aiSessionStore.upsert(makeRow("fresh-generating", { status: "generating" }));
|
|
|
|
const stale = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
|
const fresh = new Date(Date.now() - 60 * 1000).toISOString();
|
|
taskStore
|
|
.getDatabase()
|
|
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)")
|
|
.run(stale, "stale-generating", "stale-awaiting");
|
|
taskStore
|
|
.getDatabase()
|
|
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?")
|
|
.run(fresh, "fresh-generating");
|
|
|
|
const summary = aiSessionStore.cleanupStaleSessions(7 * 24 * 60 * 60 * 1000);
|
|
|
|
expect(summary.orphanedDeleted).toBe(2);
|
|
expect(aiSessionStore.get("stale-generating")).toBeNull();
|
|
expect(aiSessionStore.get("stale-awaiting")).toBeNull();
|
|
expect(aiSessionStore.get("fresh-generating")).not.toBeNull();
|
|
});
|
|
});
|