import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js"; import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js"; import { bindScopedConversationWorkspace, createConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js"; describe("owned session workspaces", () => { let base: string; let records: SessionWorkspaceStore; const owner = { actorKey: "actor-a", projectKey: "project-a", sessionId: "chat-1" }; beforeEach(async () => { base = await mkdtemp(join(tmpdir(), "tjwater-owned-chat-")); records = new SessionWorkspaceStore(join(base, "data", "session-workspaces")); }); afterEach(async () => { await rm(base, { recursive: true, force: true }); }); it("keeps messages and searchable transcripts private to their user and scope", async () => { const ui = new SessionUiStateStore(join(base, "old-ui"), records); const transcript = new SessionTranscriptStore(join(base, "old-transcripts"), records); await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] }); await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 }); expect((await new SessionUiStateStore(join(base, "old-ui"), records).read(owner))?.messages).toHaveLength(1); expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull(); expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull(); expect(await transcript.search(owner, "private")).toHaveLength(2); expect(await transcript.search({ ...owner, actorKey: "actor-b" }, "private")).toEqual([]); expect(await transcript.search({ ...owner, projectKey: "project-b" }, "private")).toEqual([]); expect((await lstat(records.path(owner, "messages.json"))).mode & 0o777).toBe(0o600); expect((await lstat(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).mode & 0o777).toBe(0o700); await expect(transcript.cloneThread(owner, { ...owner, actorKey: "actor-b" }, 2)).rejects.toThrow("across users or scopes"); }); it("has a separate personal namespace for each user and separates it from projects", async () => { const ui = new SessionUiStateStore(join(base, "old-ui"), records); const personal = { ...owner, projectKey: "personal-actor-a" }; await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] }); expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/"); expect(await ui.read(owner)).toBeNull(); expect(await ui.read({ ...personal, actorKey: "actor-b", projectKey: "personal-actor-b" })).toBeNull(); expect(() => records.path({ ...personal, projectKey: "personal-actor-b" }, "messages.json")).toThrow("owner mismatch"); }); it("preserves legacy bytes and keeps backups without overwriting later chat updates", async () => { const metadataDir = join(base, "metadata"); const uiStateDir = join(base, "ui"); const transcriptDir = join(base, "transcripts"); for (const path of [metadataDir, uiStateDir, transcriptDir]) await mkdir(path); await writeFile(join(metadataDir, "chat.json"), JSON.stringify(owner)); const messages = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "legacy" }] }); const turns = JSON.stringify({ ...owner, turns: [{ userMessage: "legacy question", assistantMessage: "legacy answer" }] }); await writeFile(join(uiStateDir, "chat.json"), messages); await writeFile(join(uiStateDir, "orphan.json"), JSON.stringify({ sessionId: "unknown", messages: [] })); await writeFile(join(transcriptDir, "chat.json"), turns); const options = { metadataDir, uiStateDir, transcriptDir }; expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 }); expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages); expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns); await new SessionUiStateStore(uiStateDir, records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] }); expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0); const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat"); const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!; expect(await readFile(join(archive, backup), "utf8")).toBe(messages); expect(await readdir(uiStateDir)).toEqual(["orphan.json"]); expect((await new SessionUiStateStore(uiStateDir, records).read(owner))?.messages).toEqual([{ text: "new answer" }]); }); it("rejects traversal, linked records and mismatched ownership", async () => { expect(() => records.path({ ...owner, actorKey: "../other-user" }, "messages.json")).toThrow("invalid workspace scope"); await records.ensure(owner); const foreign = join(base, "foreign.json"); await writeFile(foreign, "private"); await symlink(foreign, records.path(owner, "messages.json")); await expect(new SessionUiStateStore(join(base, "old-ui"), records).read(owner)).rejects.toThrow("symbolic link"); await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" })); await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch"); }); it("moves computation files beside chat records and refuses another user's binding", async () => { await records.ensure(owner); const old = await createConversationWorkspace(records.root); await writeFile(join(old, "files", "result.txt"), "kept result"); const workspace = await bindScopedConversationWorkspace(old, records.root, owner); expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result"); expect(await bindScopedConversationWorkspace(old, records.root, owner)).toBe(workspace); await expect(bindScopedConversationWorkspace(workspace, records.root, { ...owner, actorKey: "actor-b" })).rejects.toThrow("another user"); expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result"); }); it("checks full identity on history lists and rejects session filename aliases", async () => { const metadata = new SessionMetadataStore(join(base, "metadata")); await metadata.initialize(); await metadata.ensure({ ...owner, userId: "user-a", projectId: "project-a" }); expect(await metadata.list({ ...owner, userId: "user-b", projectId: "project-a" })).toEqual([]); expect(await metadata.get(owner, "CHAT-1")).toBeNull(); await expect(metadata.ensure({ ...owner, sessionId: "CHAT-1" })).rejects.toThrow("session not found"); const attempts = await Promise.allSettled([ metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-a" }), metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-b" }), ]); expect(attempts.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(attempts.filter((result) => result.status === "rejected")).toHaveLength(1); }); it("purges owned content and prevents late writes or a legacy import from reviving a deleted chat", async () => { const metadataDir = join(base, "metadata"); const uiStateDir = join(base, "ui"); const transcriptDir = join(base, "transcripts"); const learningStateDir = join(base, "learning"); for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path); const metadata = new SessionMetadataStore(metadataDir, records); const { record } = await metadata.ensure(owner); const ui = new SessionUiStateStore(uiStateDir, records); const transcript = new SessionTranscriptStore(transcriptDir, records); const learning = new SessionLearningStateStore(learningStateDir, records); const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] }); await writeFile(join(uiStateDir, "chat.json"), legacyUi); await writeFile(join(learningStateDir, `${owner.sessionId}.json`), JSON.stringify({ sessionId: owner.sessionId, lastGatedTurn: 2, lastReviewedTurn: 0, updatedAt: "2026-10-10" })); const sources = { metadataDir, uiStateDir, transcriptDir, learningStateDir }; expect((await records.migrateLegacyRecords(sources)).learningStates).toBe(1); expect((await learning.read(owner)).lastGatedTurn).toBe(2); await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 }); const workspace = await bindScopedConversationWorkspace(await createConversationWorkspace(records.root), records.root, owner); await writeFile(join(workspace, "files", "result.txt"), "private result"); const foreign = { ...owner, actorKey: "actor-b", sessionId: "chat-2" }; await ui.write(foreign, { sessionId: foreign.sessionId, messages: [{ text: "keep this" }] }); await metadata.remove(record); expect(await metadata.get(owner, owner.sessionId)).toBeNull(); expect(await metadata.list(owner)).toEqual([]); expect(await transcript.search(owner, "private")).toEqual([]); await expect(ui.write(owner, { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found"); await expect(metadata.touch(record, { title: "late title" })).rejects.toThrow("session not found"); await expect(learning.completeGate(owner, 4)).rejects.toThrow("session not found"); await metadata.purge(record); expect((await readdir(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).sort()).toEqual(["deleted.json", "owner.json"]); await expect(lstat(workspace)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(sessionWorkspaceDirectory(records.backupRoot, owner))).rejects.toMatchObject({ code: "ENOENT" }); expect((await ui.read(foreign))?.messages).toEqual([{ text: "keep this" }]); await writeFile(join(uiStateDir, "chat.json"), legacyUi); await writeFile(join(transcriptDir, "chat.json"), JSON.stringify({ ...owner, turns: [] })); expect((await records.migrateLegacyRecords(sources)).uiStates).toBe(0); expect(await ui.read(owner)).toBeNull(); expect(await readdir(uiStateDir)).toEqual([]); await expect(metadata.ensure(owner)).rejects.toThrow("session not found"); await metadata.purge(record); }); it("honors the durable index deletion when writing the workspace marker fails", async () => { const metadataDir = join(base, "metadata"); const metadata = new SessionMetadataStore(metadataDir, records); const { record } = await metadata.ensure(owner); const transcript = new SessionTranscriptStore(join(base, "legacy"), records); await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 }); const markDeleted = records.markDeleted.bind(records); records.markDeleted = async () => { throw new Error("disk write interrupted"); }; await expect(metadata.remove(record)).rejects.toThrow("disk write interrupted"); expect(await metadata.get(owner, owner.sessionId)).toBeNull(); expect(await transcript.search(owner, "private")).toEqual([]); await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found"); records = new SessionWorkspaceStore(records.root, records.backupRoot); const restarted = new SessionMetadataStore(metadataDir, records); expect(await new SessionTranscriptStore(join(base, "legacy"), records).search(owner, "private")).toEqual([]); await expect(restarted.ensure(owner)).rejects.toThrow("session not found"); await markDeleted(owner); await restarted.remove(record); await restarted.purge(record); }); });