git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
133 lines
7.6 KiB
JavaScript
133 lines
7.6 KiB
JavaScript
// 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 <data>/threads/<business>/<thread>.
|
|
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: "<html></html>" });
|
|
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");
|
|
});
|