Files
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
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.
2026-11-09 11:47:48 -06:00

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");
});