// Row 269 (264b, #1774): a thread session's store, its workspace and the // single-writer lock beside the workspace (session.mjs sessionStore(), // sessionWorkspace(), lockSessionWorkspace()), and sessions.json v2. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { startTimeOf } from "../src/proc.mjs"; import { SeatError } from "../src/seat.mjs"; import { lockSessionWorkspace, readSessions, Registry, sessionsFile, sessionStore, sessionWorkspace, threadStore } from "../src/session.mjs"; function scratch(t) { const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-ws-"))); t.after(() => rmSync(dir, { recursive: true, force: true })); return dir; } const mode = (p) => statSync(p).mode & 0o777; const refuses = (code) => (e) => e instanceof SeatError && e.code === code; test("REQ-THREAD-2 sessionStore: a legacy session keeps the thread's store, any other gets sessions//, each segment checked first", (t) => { const root = scratch(t); assert.deepEqual(sessionStore(root, "acme", "th-1", "th-1", { legacy: true }), threadStore(root, "acme", "th-1")); const s = sessionStore(root, "acme", "th-1", "s-2", { legacyThread: true }); const dir = join(root, "threads", "acme", "th-1", "sessions", "s-2"); assert.deepEqual(s, { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") }); for (const d of [dirname(dir), dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d); for (const [thread, session] of [["..", "s"], ["th-1", ".."], ["th-1", "a/b"], ["th-1", null]]) assert.throws(() => sessionStore(root, "acme", thread, session, { legacyThread: true }), refuses("thread-store-refused"), `${thread}/${session}`); assert.deepEqual(readdirSync(join(root, "threads", "acme", "th-1", "sessions")), ["s-2"], "a refused call makes nothing"); }); test("REQ-THREAD-2 sessionStore refuses legacy-store-unmapped for a thread whose old store no legacy session maps", (t) => { const root = scratch(t); threadStore(root, "acme", "th-1"); assert.throws(() => sessionStore(root, "acme", "th-1", "s-1"), (e) => refuses("legacy-store-unmapped")(e) && /holds history no legacy session maps$/.test(e.message)); assert.equal(existsSync(join(root, "threads", "acme", "th-1", "sessions")), false); // A thread with a legacy session: its other sessions take D2's layout. assert.ok(sessionStore(root, "acme", "th-1", "s-1", { legacyThread: true }).dir.endsWith(join("sessions", "s-1"))); const elsewhere = join(root, "elsewhere"); mkdirSync(elsewhere, { mode: 0o700 }); symlinkSync(elsewhere, join(root, "threads", "acme", "th-2")); assert.throws(() => sessionStore(root, "acme", "th-2", "s-1"), (e) => refuses("thread-store-refused")(e) && /is unsafe$/.test(e.message)); assert.deepEqual(readdirSync(elsewhere), []); }); test("REQ-THREAD-2 sessionWorkspace: private directories per session, unsafe segments and overlaps refused", (t) => { const root = scratch(t); const ws = sessionWorkspace(root, "acme", "assistant", "s-1"); assert.equal(ws, join(root, "session-workspaces", "acme", "assistant", "s-1")); for (const d of [join(root, "session-workspaces"), dirname(dirname(ws)), dirname(ws), ws]) assert.equal(mode(d), 0o700, d); assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1"), ws, "a second call reuses it"); assert.notEqual(sessionWorkspace(root, "acme", "assistant", "s-2"), ws); for (const session of ["..", "a/b", ""]) assert.throws(() => sessionWorkspace(root, "acme", "assistant", session), refuses("session-workspace-unsafe"), session); // A live workspace at the same path is the lock's to refuse; one above or below it overlaps. assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1", { live: [ws] }), ws); assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-3", { live: [dirname(ws)] }), refuses("session-workspace-overlap")); // The legacy root symlinked into a session path: every segment is real, only the realpath finds it. mkdirSync(join(root, "workspaces"), { mode: 0o700 }); symlinkSync(ws, join(root, "workspaces", "acme")); assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-1"), refuses("session-workspace-overlap")); // A symlinked root fails the segment check first. const other = scratch(t); const elsewhere = join(other, "elsewhere"); mkdirSync(elsewhere, { mode: 0o700 }); symlinkSync(elsewhere, join(other, "session-workspaces")); assert.throws(() => sessionWorkspace(other, "acme", "assistant", "s-1"), refuses("session-workspace-unsafe")); assert.deepEqual(readdirSync(elsewhere), []); }); test("REQ-THREAD-2 lockSessionWorkspace: one writer; a live host or run holds it, a stale lock is taken over, an unreadable one holds", async (t) => { const root = scratch(t); const ws = sessionWorkspace(root, "acme", "assistant", "s-1"); const me = { hostPid: process.pid, hostStartTime: startTimeOf(process.pid) }; const lock = lockSessionWorkspace(ws, me); assert.equal(lock.file, `${ws}.lock`); assert.equal(mode(lock.file), 0o600); assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy")); lock.update({ run: "r-1", runPid: 1, runStartTime: "x" }); assert.deepEqual(JSON.parse(readFileSync(lock.file, "utf8")), { ...me, run: "r-1", runPid: 1, runStartTime: "x" }); lock.release(); assert.equal(existsSync(lock.file), false); // A run that outlives its host still holds it. const run = spawn("sleep", ["60"], { stdio: "ignore" }); const gone = new Promise((r) => run.once("exit", r)); t.after(() => run.kill("SIGKILL")); let runStart = null; for (let i = 0; i < 100 && !runStart; i++) { runStart = startTimeOf(run.pid); if (!runStart) await new Promise((r) => setTimeout(r, 20)); } const held = `${JSON.stringify({ hostPid: 2 ** 22 + 7, hostStartTime: "gone", runPid: run.pid, runStartTime: runStart })}\n`; writeFileSync(`${ws}.lock`, held, { mode: 0o600 }); assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy")); assert.equal(readFileSync(`${ws}.lock`, "utf8"), held); run.kill("SIGKILL"); await gone; // Now stale: taken over, and nothing is left beside it. const taken = lockSessionWorkspace(ws, me); assert.deepEqual(JSON.parse(readFileSync(taken.file, "utf8")), me); assert.deepEqual(readdirSync(dirname(ws)).sort(), ["s-1", "s-1.lock"]); // One release per lock: a second doesn't remove a lock written since. taken.release(); writeFileSync(`${ws}.lock`, "{}\n", { mode: 0o600 }); taken.release(); assert.equal(readFileSync(`${ws}.lock`, "utf8"), "{}\n"); // An unreadable lock may be one being written, so it holds. writeFileSync(`${ws}.lock`, "{", { mode: 0o600 }); assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy")); }); test("REQ-THREAD-2 sessions.json v2: entries carry session and workspace, running() keys on the session, a v1 file still reads", (t) => { const root = scratch(t); const registry = new Registry(root); const entry = { business: "acme", instance: "assistant", thread: "th-1", session: "s-1", workspace: "/w", run: "r-1" }; registry.add(entry); assert.equal(JSON.parse(readFileSync(sessionsFile(root), "utf8")).sessionsVersion, 2); assert.deepEqual(registry.running("acme", "assistant", "th-1", "s-1"), entry); assert.equal(registry.running("acme", "assistant", "th-1", "s-2"), null); assert.equal(registry.running("acme", "assistant", "th-1"), null, "an entry with a session isn't the thread's sessionless run"); writeFileSync(sessionsFile(root), `${JSON.stringify({ sessionsVersion: 1, sessions: [{ business: "acme", instance: "assistant", thread: "th-1", run: "r-0" }] })}\n`); assert.deepEqual(readSessions(root).map((s) => [s.run, s.session ?? null]), [["r-0", null]]); });