// Case 2 M7 (design 5.1, row 220): a creative seat's conversations are hidden from // the catalogue, from open and from next. The thread store is a second wall: a // root built over it is hidden, and no spec under .pi/state reaches it. A business // file the board can't read refuses the listing and open, never a partial list. import { test } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { conversationResponse, creativeCheck, startServer, REFUSAL_STATUS } from "../src/serve.mjs"; import { conversationId, createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; import { businessDoc, writeJson } from "../../organization/tests/helpers.mjs"; import { mappingEntry } from "../../library/tests/helpers.mjs"; // A config directory holding one library business whose seat "bean" is creative, // and a thread store for bean's session under /threads//. function fixture(t) { const root = mkdtempSync(join(tmpdir(), "row220-board-")); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, "config"); const doc = businessDoc(root, "acme"); doc.projects = { books: mappingEntry(), ...doc.projects }; doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; doc.roles.bean = { definition: "companion" }; doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; writeJson(join(dir, "businesses", "acme.json"), doc); const threadDir = join(root, "data", "threads", "acme", "t1", "session"); mkdirSync(threadDir, { recursive: true }); writeFileSync(join(threadDir, "s.jsonl"), "{}\n"); const threadRoot = { seat: "bean", project: "acme", projectRoot: join(root, "data"), dir: threadDir, harness: "pi", unsupportedReason: null, engineStartedAt: null }; return { root, dir, threadDir, threadRoot }; } function breakBusinessFile(dir) { const file = join(dir, "businesses", "broken.json"); writeFileSync(file, "{"); chmodSync(file, 0o600); } const detail = (id) => new URL(`/api/conversation?id=${encodeURIComponent(id)}`, "http://localhost"); const listing = new URL("/api/conversations", "http://localhost"); test("M7: the check hides the creative seat, shows the others, and refuses when a business file is unreadable", (t) => { const f = fixture(t); const hidden = creativeCheck(f.dir)(); assert.equal(hidden({ seat: "bean" }), true); assert.equal(hidden({ seat: "pm" }), false); breakBusinessFile(f.dir); assert.throws(() => creativeCheck(f.dir)(), (err) => err.code === "conversation-business-unreadable"); }); test("M7: a thread store is not a root from a spec, and a root built over it is hidden from the list and from open", (t) => { const f = fixture(t); // The spec layout check in rootsFromSpecs drops a thread-store directory. assert.deepEqual(rootsFromSpecs([{ agent: "bean", project: "acme", sessionsDir: f.threadDir }]), []); const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); const cat = reader.catalogue(); assert.deepEqual([cat.conversations, cat.refusedRoots], [[], []], "the root is hidden, not refused"); const out = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); assert.equal(out.status, REFUSAL_STATUS["conversation-not-listed"]); assert.equal(out.status, 404); assert.equal(out.body.refusal.code, "conversation-not-listed"); }); test("M7: with an unreadable business file, a known and an unknown id both refuse with 503", (t) => { const f = fixture(t); breakBusinessFile(f.dir); const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); const known = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); const unknown = conversationResponse(reader, detail(conversationId(f.threadRoot, "missing.jsonl"))); for (const out of [known, unknown]) { assert.equal(out.status, 503); assert.equal(out.body.refusal.code, "conversation-business-unreadable"); } }); test("M7: an unreadable business file refuses the listing and open with 503, never a partial list", (t) => { const f = fixture(t); breakBusinessFile(f.dir); const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); const cat = conversationResponse(reader, listing); assert.equal(cat.status, 503); assert.equal(cat.body.refusal.code, "conversation-business-unreadable"); assert.equal(cat.body.conversations, undefined); const opened = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); assert.equal(opened.status, REFUSAL_STATUS["conversation-business-unreadable"]); assert.equal(opened.status, 503); }); // C2: every test above builds its own reader. This one starts the board with no // injected reader, so the check reaches the reader only through createServer's // wiring (serve.mjs: `creativeCheck: creativeCheck(configDirectory)`). Deleting that // line shows bean's session in the listing and fails the first assertion below. // It also shows the business file in the fixture's config directory is the one // read: bean is hidden and pm is not, and no other file says so. test("M7: the board's default reader hides a creative seat's session from the listing and the detail route", async (t) => { const f = fixture(t); const proj = join(f.root, "proj"); const specs = []; for (const [n, seat] of ["bean", "pm"].entries()) { const sessionsDir = join(proj, ".pi", "state", seat, "sessions"); mkdirSync(sessionsDir, { recursive: true }); const header = { type: "session", version: 3, id: `c0ffee00-0000-4000-8000-00000000000${n + 1}`, timestamp: "2026-10-22T12:00:00.000Z", cwd: proj }; const message = { type: "message", id: "e0", parentId: null, timestamp: "2026-10-22T12:00:01.000Z", message: { role: "user", content: [{ type: "text", text: `${seat.toUpperCase()}-TEXT` }] } }; writeFileSync(join(sessionsDir, "s.jsonl"), [header, message].map((l) => JSON.stringify(l)).join("\n") + "\n"); specs.push({ agent: seat, project: "proj", sessionsDir, tmux: {} }); } const roots = rootsFromSpecs(specs); const idOf = (seat) => conversationId(roots.find((r) => r.seat === seat), "s.jsonl"); const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(f.root, "board"), seatsDir: join(f.root, "seats"), configDirectory: f.dir, isAlive: () => true, page: "" }); t.after(() => { server.closeAllConnections(); server.close(); }); const base = `http://127.0.0.1:${server.address().port}`; const get = async (path) => { const res = await fetch(base + path); return { status: res.status, text: await res.text() }; }; const cat = await get("/api/conversations"); assert.equal(cat.status, 200, cat.text); assert.deepEqual(JSON.parse(cat.text).conversations.map((c) => c.seat), ["pm"]); assert.ok(!cat.text.includes("BEAN-TEXT")); const hidden = await get(`/api/conversation?id=${encodeURIComponent(idOf("bean"))}`); assert.equal(hidden.status, 404, hidden.text); assert.equal(JSON.parse(hidden.text).refusal.code, "conversation-not-listed"); assert.ok(!hidden.text.includes("BEAN-TEXT")); const shown = await get(`/api/conversation?id=${encodeURIComponent(idOf("pm"))}`); assert.equal(shown.status, 200, shown.text); // The file is read on every call: a broken business file now refuses the same route. breakBusinessFile(f.dir); const refused = await get("/api/conversations"); assert.equal(refused.status, 503, refused.text); assert.equal(JSON.parse(refused.text).refusal.code, "conversation-business-unreadable"); });