Merge branch 'org/s1' into org/s5

# Conflicts:
#	scripts/mosaic
This commit is contained in:
jason.woltje committed 2026-11-07 11:32:05 -06:00
commit 487a2e7fa0
18 files changed
+606 -208

No files matched your search

+98 -51
View File
@@ -9,7 +9,7 @@
// --pm it also launches the business's PM (src/launcher.mjs).
import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { basename, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { BINDING_NAME } from "../../discord/src/binding.mjs";
@@ -28,7 +28,7 @@ import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { DEV_OWNER, devOwnerInstance } from "./dev-local-owner.mjs";
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
@@ -38,6 +38,9 @@ import { createLauncher } from "./launcher.mjs";
import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg } from "../../business/src/org-registry.mjs";
import { busStart, busStatus, busStop, busRestart } from "./org-bus.mjs";
import { consoleStart, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -71,12 +74,14 @@ export const USAGE = `usage:
mosaic halt [--business <id>]
mosaic resume [--business <id>]
mosaic processing status [--business <id>] [--json]
mosaic stop <run>
mosaic stop <run> --business <id>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner]
mosaic bus stop
mosaic bus status [--json]`;
mosaic launches list --business <id> [--json]
mosaic bus start <id> [--pm | --no-launch] [--dev-local-owner] [--detach]
mosaic bus stop|restart <id>
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner] [--detach] [--port auto|N]
mosaic console stop`;
// packages/seat/src/cli.mjs USAGE, which that module does not export (it runs
// its main on import). tests/app.test.mjs fails if the two drift.
@@ -202,10 +207,18 @@ function callFor(parsed, io, deps) {
const tester = parsed.flags.has("--tester");
if (!tester) refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
const business = parsed.values["--business"] ?? (deps.transport || deps.testerTransport ? businessFor(parsed, system.dataRoot) : null);
if (!business) throw usage("name the organization with --business");
parsed.business = business;
if (tester) return deps.testerTransport ? deps.testerTransport(business) : testerTransport({ socket: socketPath(system.dataRoot), business, env: io.env, spawn: deps.testerSpawn });
return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
if (tester && deps.testerTransport) return deps.testerTransport(business);
if (!tester && deps.transport) return deps.transport(business);
return async (verb, args) => {
const org = await resolveOrg(organizationContext(io, deps), business);
if (org.state !== "active") throw new CliError("organization is not active", 3);
const options = { socket: orgPaths(org.root).socket, business, env: io.env };
const call = tester ? testerTransport({ ...options, spawn: deps.testerSpawn }) : humanTransport(options);
return call(verb, args);
};
}
@@ -482,11 +495,13 @@ async function seat(fn) {
}
async function stop(rest, io, deps) {
const parsed = parse(rest, {});
const parsed = parse(rest, { values: ["--business"] });
if (parsed.positional.length !== 1) throw usage("stop takes one run id; see mosaic launches list");
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const r = await seat(() => stopSession(system.dataRoot, parsed.positional[0], deps.stopTimeoutMs ? { timeoutMs: deps.stopTimeoutMs } : {}));
const id = parsed.values["--business"];
if (!id && !deps.system) throw usage("stop needs --business");
const root = id ? (await resolveOrg(organizationContext(io, deps), id)).root : deps.system.dataRoot;
const r = await seat(() => stopSession(root, parsed.positional[0], deps.stopTimeoutMs ? { timeoutMs: deps.stopTimeoutMs } : {}));
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
}
@@ -526,18 +541,18 @@ async function launches(rest, io, deps) {
const parsed = parse(args, { values: ["--business"] });
if (parsed.positional.length !== 0) throw usage(`launches ${sub} takes no arguments`);
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
const call = callFor(parsed, io, deps);
await call(sub === "off" ? "launch.revoke" : "launch.restore");
io.stdout.write(sub === "off" ? `launches off for ${business}: the PM's launch requests are refused until mosaic launches on; running sessions keep running\n` : `launches on for ${business}\n`);
return;
}
if (sub === "list") {
const parsed = parse(args, { flags: ["--json"] });
const parsed = parse(args, { flags: ["--json"], values: ["--business"] });
if (parsed.positional.length !== 0) throw usage("launches list takes no arguments");
const system = deps.system ?? loadSystem({ env: io.env });
const list = await seat(() => readSessions(system.dataRoot));
const id = parsed.values["--business"];
if (!id && !deps.system) throw usage("launches list needs --business");
const root = id ? (await resolveOrg(organizationContext(io, deps), id)).root : deps.system.dataRoot;
const list = await seat(() => readSessions(root));
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
if (list.length === 0) return io.stdout.write("no sessions\n");
const lines = list.map((s) => `${s.run} ${s.business}/${s.instance} ${s.harness} ${s.model} by ${s.launchedBy} since ${s.startedAt} ${s.live ? "running" : "not running (stale)"}`);
@@ -546,33 +561,75 @@ async function launches(rest, io, deps) {
throw usage();
}
function organizationContext(io, deps) {
const dir = configDir(io.env);
const match = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
if (!match) throw new CliError("organization commands require an instance config directory", 3);
const instance = match[1];
const system = deps.system ?? loadSystem({ env: io.env, instance });
return { configDir: dir, dataRoot: system.dataRoot, instance, env: io.env, system };
}
async function consoleCommand(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "stop") {
if (args.length) throw usage("console stop takes no arguments");
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps)))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments");
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: parsed.flags.has("--dev-local-owner"), detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto" });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
}
}
async function bus(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "start") {
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
const ctx = organizationContext(io, deps);
const businessId = parsed.positional[0];
if (!parsed.flags.has("--internal-host")) {
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
if (code !== 0) throw new CliError("bus host stopped after a child exited", code);
}
return;
}
const org = await resolveOrg(ctx, businessId);
if (org.state !== "active") throw new CliError("organization is not active", 3);
orgPaths(org.root);
if (ctx.system.proxy) throw new CliError("shared proxy isolation is not available until S2 is integrated", 3);
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
const launches = !parsed.flags.has("--no-launch");
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
const instance = parsed.flags.has("--dev-local-owner") ? devOwnerInstance(io.env) : null;
const businessId = parsed.positional[0];
const system = instance ? loadSystem({ env: io.env, instance }) : (deps.system ?? loadSystem({ env: io.env }));
const system = ctx.system;
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
// `bus status` can read it, then thrown as before. No retry.
const file = businessFilePath(businessId, configDir(io.env));
let boot;
try {
boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
boot = bootConfig({ system, businessId, orgRoot: org.root, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
} catch (e) {
try { recordBootRefused(system.dataRoot, { business: businessId, file, reason: e.message }); } catch { /* the original refusal still throws */ }
try { recordBootRefused(org.root, { business: businessId, file, reason: e.message }); } catch { /* the original refusal still throws */ }
throw e;
}
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
try { recordBootOk(system.dataRoot, { business: businessId, file }); } catch { /* status is advisory */ }
const binding = readNotifyConfig(system.dataRoot, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
const binding = readNotifyConfig(org.root, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } });
try {
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
@@ -586,6 +643,7 @@ async function bus(rest, io, deps) {
io.stdout.write('processing halted: chief launch skipped; control bus remains available\n');
}
}
if (process.send) process.send({ orgReady: businessId });
} catch (e) {
await host.close(1);
throw e;
@@ -600,30 +658,14 @@ async function bus(rest, io, deps) {
if (code !== 0) throw new CliError("bus host stopped after a child exited", code);
return;
}
if (sub === "stop") {
if (args.length !== 0) throw usage("bus stop takes no arguments");
const system = deps.system ?? loadSystem({ env: io.env });
const r = await stopHost(system.dataRoot);
io.stdout.write(r.stopped ? `stopped bus host for ${r.business} (pid ${r.pid})\n` : `${r.reason}\n`);
if (["stop", "restart", "status"].includes(sub)) {
const parsed = parse(args, { flags: sub === "status" ? ["--json"] : [] });
if (parsed.positional.length !== 1) throw usage(`bus ${sub} takes one organization`);
const ctx = organizationContext(io, deps), id = parsed.positional[0];
const result = sub === "stop" ? await busStop(ctx, id) : sub === "restart" ? await busRestart(ctx, id) : await busStatus(ctx, id);
io.stdout.write(`${JSON.stringify(result, null, parsed.flags.has("--json") ? 2 : 0)}\n`);
return;
}
if (sub === "status") {
const parsed = parse(args, { flags: ["--json"] });
if (parsed.positional.length !== 0) throw usage("bus status takes no arguments");
const system = deps.system ?? loadSystem({ env: io.env });
const s = hostStatus(system.dataRoot);
s.boot = readBootStatus(system.dataRoot);
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(s, null, 2)}\n`);
const lines = [
s.host ? `host: ${s.host.business}, pid ${s.host.pid}, ${s.host.live ? "running" : "not running (stale state file)"}, since ${s.host.startedAt}, notifier ${s.host.notifier ?? "off"}` : "host: none",
`socket: ${s.socket ? "present" : "absent"}`,
s.writerLock
? `writer.lock: pid ${s.writerLock.pid ?? "?"}${s.writerLock.live ? "" : " (not running: a broker died hard; remove the lock by hand once you've checked, see packages/bus/README.md)"}`
: "writer.lock: absent",
bootLine(s.boot) + (s.boot && !s.boot.ok && s.host?.live ? " (a host started earlier still runs on the old file)" : ""),
];
return io.stdout.write(`${lines.join("\n")}\n`);
}
throw usage();
}
@@ -686,6 +728,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["halt", "resume", "processing"].includes(verb)) return processingCommand(verb, rest, io, deps);
if (verb === "launches") return launches(rest, io, deps);
if (verb === "bus") return bus(rest, io, deps);
if (verb === "console") return consoleCommand(rest, io, deps);
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
throw usage();
}
@@ -703,6 +746,10 @@ export async function run(argv, io = { env: process.env, stdin: process.stdin, s
await main(argv, io, deps);
return 0;
} catch (error) {
if (error instanceof OrgError) {
io.stderr.write(`mosaic: ${error.code}: ${error.message}\n`);
return 3;
}
if (!(error instanceof CliError)) throw error;
io.stderr.write(`mosaic: ${error.message}\n`);
if (OTHER_TREE_CODES.has(error.code)) io.stderr.write(`mosaic: ${OTHER_TREE}\n`);
+21 -4
View File
@@ -6,7 +6,7 @@
import { stackBinding } from "../../tasks/src/builtin.mjs";
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { managedState } from "../../business/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
@@ -21,6 +21,22 @@ export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..",
// `<dataRoot>/bus/writer.lock` (packages/bus/src/store.mjs).
export const socketPath = (dataRoot) => join(dataRoot, "bus", "broker.sock");
// Linux sockaddr_un.sun_path holds at most 107 bytes plus its terminator.
// Check every socket at the boundary, before starting any host or child.
export function orgPaths(root) {
if (typeof root !== "string" || !isAbsolute(root) || resolve(root) !== root || root.includes("\0")) {
throw new CliError("organization root must be a canonical absolute path", 3);
}
const busDir = join(root, "bus"), hostDir = join(root, "bus-host");
const socket = join(busDir, "broker.sock");
if ([socket, join(hostDir, "launch.sock"), join(hostDir, "git.sock")].some((p) => Buffer.byteLength(p) > 107)) {
throw new CliError("organization socket path is too long", 3);
}
return { socket, busDir, runs: join(root, "runs"), launches: join(root, "launches"),
sessions: join(root, "sessions"), workspaces: join(root, "workspaces"),
threads: join(root, "threads"), hostDir };
}
export function loadSystem({ env = process.env, instance = null } = {}) {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate", ...(instance === null ? [] : ["--instance", instance])], {
encoding: "utf8",
@@ -128,21 +144,22 @@ export const consoleOwners = (sso) =>
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
export function bootConfig({ system, businessId, env = process.env, warn = () => {} }) {
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
dataRoot: system.dataRoot,
dataRoot: orgRoot,
...(orgRoot === system.dataRoot ? {} : { instanceRoot: system.dataRoot }),
...(system.proxy ? { proxy: system.proxy } : {}),
businesses: { [b.id]: busBusiness(b, resolved) },
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
};
const tracker = trackerFor(vars, b, warn, system.dataRoot);
const tracker = trackerFor(vars, b, warn, orgRoot);
if (tracker) {
config.trackers = { [b.id]: tracker.tracker };
if (tracker.builtinBinding) config.builtinBinding = tracker.builtinBinding;
+8 -7
View File
@@ -76,7 +76,7 @@ const same = (a, b) => a?.pid === b?.pid && a?.startTime === b?.startTime && a?.
// tried again, like one that goes before the check. `found` runs once the
// lock is found and before it is read, `between` after the stale lock is
// read; both are for the tests. Exported for the tests.
export function claimHostLock(dataRoot, state, { found = () => {}, between = () => {} } = {}) {
export function claimHostLock(dataRoot, state, { found = () => {}, between = () => {}, refuseStale = false } = {}) {
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
const file = hostLock(dataRoot);
const tag = `${process.pid}.${randomBytes(4).toString("hex")}`;
@@ -94,6 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
between();
try {
renameSync(file, aside);
@@ -168,7 +169,7 @@ export function watchChildren(children, onDeath) {
// {binding, base?, pollMs?}; base and pollMs exist for the tests, and the
// command line never sets them, nor requestTimeoutMs. Resolves once both
// children are up.
export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, requestTimeoutMs = REQUEST_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
export async function startHost({ boot, business, notifier = null, options = null, refuseStale = false, bootTimeoutMs = BOOT_TIMEOUT_MS, requestTimeoutMs = REQUEST_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
const dataRoot = boot.dataRoot;
// A host started before host.lock existed holds only host.json.
const prior = readHostState(dataRoot);
@@ -176,8 +177,8 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
// host.lock is this host's from here until it stops, and a start that
// fails gives it back. host.json comes once the children are up, so a
// reader never finds a booting host with no broker socket.
const state = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business, startedAt: new Date().toISOString(), notifier: notifier ? notifier.binding : null };
claimHostLock(dataRoot, state);
const state = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business, startedAt: new Date().toISOString(), notifier: notifier ? notifier.binding : null, ...(options ? { options } : {}) };
claimHostLock(dataRoot, state, { refuseStale });
// A lock this host can't read isn't its own, so it stays.
const release = () => {
let held = null;
@@ -241,7 +242,7 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
if (notifier) {
notify = fork(NOTIFIER, [], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
notify.on("error", onChildError("notifier"));
notify.send({ op: "start", path: ready.path, cap: reader.cap, business, dataRoot, binding: notifier.binding, base: notifier.base, pollMs: notifier.pollMs });
notify.send({ op: "start", path: ready.path, cap: reader.cap, business, dataRoot, instanceRoot: boot.instanceRoot ?? dataRoot, binding: notifier.binding, base: notifier.base, pollMs: notifier.pollMs });
const ok = await firstReply(notify, START_TIMEOUT_MS, "notifier");
if (ok?.ok !== true) throw new CliError(`notifier refused to start: ${typeof ok?.error === "string" ? ok.error : "notifier-refused"}`, 3);
}
@@ -368,12 +369,12 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
// pid still runs with the recorded start time and is a `bus start` process.
export async function stopHost(dataRoot, { timeoutMs = 60000 } = {}) {
export async function stopHost(dataRoot, { timeoutMs = 60000, expectedBusiness = null } = {}) {
const state = readHostState(dataRoot);
if (!state || !state.live) return { stopped: false, reason: state ? "stale state file; no host runs" : "no host runs" };
const argv = cmdlineOf(state.pid) ?? [];
const i = argv.findIndex((a) => a.endsWith("/packages/cli/src/cli.mjs"));
if (i < 0 || argv[i + 1] !== "bus" || argv[i + 2] !== "start") {
if (i < 0 || argv[i + 1] !== "bus" || argv[i + 2] !== "start" || (expectedBusiness !== null && (state.business !== expectedBusiness || argv[i + 3] !== expectedBusiness))) {
throw new CliError(`pid ${state.pid} is not a bus host; refusing to signal it`, 3);
}
process.kill(state.pid, "SIGTERM");
+1 -1
View File
@@ -255,7 +255,7 @@ export function createLauncher({
}
}
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
const skillsDir = join(dataRoot, "skills-enabled");
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const vars = systemVars(system);
+1 -1
View File
@@ -39,7 +39,7 @@ if (!process.send) {
try {
const client = new Client({ path: m.path, cap: m.cap });
const processing = { work: fn => withProcessing(m.dataRoot, fn), guard: () => currentProcessing()?.guard() };
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}),
const direct = openDirect({ dataRoot: m.instanceRoot ?? m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}),
fetch: (url, init) => {
const scope = currentProcessing(); scope?.guard();
return fetch(url, { ...init, ...(scope ? { signal: scope.signal } : {}) });
+112
View File
@@ -0,0 +1,112 @@
// Organization-scoped bus lifecycle. Registry resolution precedes every action.
import { spawn } from 'node:child_process';
import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveOrg } from '../../business/src/org-registry.mjs';
import { orgPaths } from './config.mjs';
import { cmdlineOf, hostLock, hostStatus, readHostState, startTimeOf, stopHost } from './host.mjs';
import { CliError } from './errors.mjs';
const CLI = fileURLToPath(new URL('./cli.mjs', import.meta.url));
const fail = message => { throw new CliError(message, 3); };
async function target(ctx, id) {
let org;
try { org = await resolveOrg(ctx, id); }
catch (error) {
if (['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) fail('organization registry is inaccessible');
throw error;
}
if (org.state !== 'active') fail(`organization ${id} is not active`);
return { ...org, paths: orgPaths(org.root) };
}
function checkedState(org) {
const s = hostStatus(org.root);
let lock = null;
try { lock = JSON.parse(readFileSync(hostLock(org.root), 'utf8')); }
catch (e) { if (e.code !== 'ENOENT') fail('bus host lock is unreadable'); }
if (lock && (!Number.isSafeInteger(lock.pid) || typeof lock.startTime !== 'string' || startTimeOf(lock.pid) !== lock.startTime)) fail('stale bus host lock');
if (s.host && (!s.host.live || s.host.business !== org.id)) fail('stale or mismatched bus host state');
if (s.host) {
const args = cmdlineOf(s.host.pid) ?? [], i = args.indexOf(CLI);
if (i < 0 || args[i + 1] !== 'bus' || args[i + 2] !== 'start' || args[i + 3] !== org.id) fail('bus host identity mismatch');
}
if (lock && (!s.host || lock.pid !== s.host.pid || lock.business !== org.id)) fail('bus host lock does not match host state');
if (s.writerLock && !s.writerLock.live) fail('stale bus writer lock');
if (!s.host && s.writerLock) fail('bus writer lock has no matching host');
if (s.host && !lock) fail('bus host lock is missing');
return s;
}
function answer(path) {
return new Promise(resolve => {
let done = false, data = '';
const socket = connect(path);
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
socket.setTimeout(1000, () => finish(false));
socket.on('error', () => finish(false));
socket.on('connect', () => socket.write('{}\n'));
socket.on('data', chunk => {
data += chunk;
if (data.length > 4096) return finish(false);
if (data.includes('\n')) {
try { const r = JSON.parse(data.split('\n')[0]); finish(r.ok === false && r.error === 'invalid-envelope'); }
catch { finish(false); }
}
});
socket.on('end', () => finish(false));
});
}
export async function busStatus(ctx, id) {
const org = await target(ctx, id), state = checkedState(org);
const healthy = Boolean(state.host?.live && state.socket && await answer(org.paths.socket));
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null };
}
export async function busStop(ctx, id) {
const org = await target(ctx, id);
checkedState(org);
return stopHost(org.root, { expectedBusiness: id });
}
export async function busStart(ctx, id, { detach = false, devLocalOwner = false, pm = false, noLaunch = false } = {}) {
if (pm && noLaunch) fail('bus start takes --pm or --no-launch, not both');
const org = await target(ctx, id);
checkedState(org);
if (readHostState(org.root)?.live) fail(`bus already runs for ${id}`);
mkdirSync(org.paths.hostDir, { recursive: true, mode: 0o700 });
const st = lstatSync(org.paths.hostDir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe bus host directory');
const fd = openSync(join(org.paths.hostDir, 'host.log'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
let child;
try {
child = spawn(process.execPath, [CLI, 'bus', 'start', id, '--internal-host', ...(devLocalOwner ? ['--dev-local-owner'] : []), ...(pm ? ['--pm'] : []), ...(noLaunch ? ['--no-launch'] : [])], {
env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd, 'ipc'],
});
} finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false, ready = false;
child.on('message', message => { if (message?.orgReady === id) ready = true; });
done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
// During boot the host holds its lock before its state is published.
const state = readHostState(org.root);
if (ready && state?.live && state.pid === child.pid && state.business === id && await answer(org.paths.socket)) {
child.disconnect();
if (detach) child.unref();
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
Object.defineProperty(receipt, 'done', { value: done });
return receipt;
}
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
checkedState(org);
const state = readHostState(org.root);
const options = state?.options ?? {};
await busStop(ctx, id);
return busStart(ctx, id, { detach: true, ...options });
}
+83
View File
@@ -0,0 +1,83 @@
// Instance-wide Console process; organization routing is owned by S5.
import { spawn } from 'node:child_process';
import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
const fail = message => { throw new CliError(message, 3); };
const directory = ctx => join(ctx.dataRoot, 'console');
const stateFile = ctx => join(directory(ctx), 'console.json');
function state(ctx) {
let data;
try { data = JSON.parse(readFileSync(stateFile(ctx), 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return null; fail('console state is unreadable'); }
if (!Number.isSafeInteger(data?.pid) || typeof data.startTime !== 'string' || typeof data.url !== 'string') fail('console state is invalid');
return data;
}
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe console directory');
}
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto' } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
checkDir(ctx);
const lock = join(directory(ctx), 'start.lock');
let lockFd;
try { lockFd = openSync(lock, 'wx', 0o600); } catch { fail('console start lock exists'); }
let child = null, done = null;
try {
if (state(ctx)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
const match = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(text);
if (match) {
if (forbidden.has(Number(match[2]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1] }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
const result = { url: match[1], pid: child.pid };
Object.defineProperty(result, 'done', { value: done });
return result;
}
await new Promise(r => setTimeout(r, 50));
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
} finally { closeSync(lockFd); rmSync(lock, { force: true }); }
}
export async function consoleStop(ctx) {
const s = state(ctx);
if (!s) return { stopped: false };
if (startTimeOf(s.pid) !== s.startTime) fail('stale console state');
const argv = cmdlineOf(s.pid) ?? [];
const i = argv.findIndex(a => a === WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') fail('console pid does not match the recorded process');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
if (startTimeOf(s.pid) !== s.startTime) { rmSync(stateFile(ctx)); return { stopped: true, pid: s.pid }; }
await new Promise(r => setTimeout(r, 100));
}
fail('console did not stop');
}
+1 -1
View File
@@ -154,7 +154,7 @@ test("every human command refuses inside an agent run before it touches the bus"
test("usage errors exit 4; no business and no host is a usage error", async (t) => {
const s = setup(t);
for (const argv of [[], ["nope"], ["inbox", "extra", "--business", "demo"], ["inbox", "--bogus"], ["trail", "--business", "demo"], ["decide", "x", "--business", "demo"], ["bus"], ["bus", "stop", "x"], ["inbox"]]) {
for (const argv of [[], ["nope"], ["inbox", "extra", "--business", "demo"], ["inbox", "--bogus"], ["trail", "--business", "demo"], ["decide", "x", "--business", "demo"], ["bus"], ["bus", "stop"], ["inbox"]]) {
assert.equal((await s.run(argv)).code, 4, argv.join(" ") || "(none)");
}
assert.equal(s.calls.length, 0);
+88 -135
View File
@@ -4,13 +4,15 @@ import { spawn, spawnSync } from "node:child_process";
import { subscribe, unsubscribe } from "node:diagnostics_channel";
import { createServer } from "node:http";
import { once } from "node:events";
import { chmodSync, existsSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, mkdirSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { bootConfig, loadSystem, REPO } from "../src/config.mjs";
import { claimHostLock, hostDir, hostFile, hostLock, hostStatus, readHostState, startHost, startTimeOf, stopHost, watchChildren } from "../src/host.mjs";
import { journalPath } from "../src/notifier.mjs";
import { reserveOrg, activateOrg } from "../../business/src/org-registry.mjs";
import { busStart, busStatus, busStop } from "../src/org-bus.mjs";
import { IDS, makeDeployment } from "../../discord/tests/helpers.mjs";
import { fixture, notifyConfig, OPTIONS, tmp } from "./helpers.mjs";
@@ -18,6 +20,22 @@ const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
const CHANNEL = "100000000000000900";
const TOKEN = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789";
// A named scratch instance with an active organization. Keep the old fixture's
// business and role documents; only the instance/organization layout changes.
async function orgFixture(t, { notify = true } = {}) {
const root = tmp(t), f = fixture(root), instance = "hosttest";
const dir = join(root, ".config", `mosaic-${instance}`);
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
renameSync(join(root, "config"), dir);
chmodSync(dir, 0o700); // S0 requires private instance configuration directories.
const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(dir, "config.json") };
writeFileSync(join(f.dataRoot, "instance.json"), JSON.stringify({ instanceVersion: 1, name: instance }), { mode: 0o600 });
const ctx = { configDir: dir, dataRoot: f.dataRoot, instance, env };
const org = await reserveOrg(ctx, "acme"); await activateOrg(ctx, "acme");
if (notify) notifyConfig(org.root, "acme", null);
return { ...f, env, ctx, instanceRoot: f.dataRoot, dataRoot: org.root };
}
// A fake Discord REST on 127.0.0.1: opens one DM channel, accepts messages.
async function fakeDiscord(t) {
const requests = [];
@@ -247,35 +265,14 @@ test("a second host for the same data root refuses with exit 3 while the first r
assert.equal(await host.close(0), 0);
});
test("two bus starts racing for one data root leave one host running; the other refuses that a host already runs", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const starts = [0, 1].map(() => {
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"));
const run = { child, out: "", exit: once(child, "exit") };
child.stdout.on("data", (b) => (run.out += b));
child.stderr.on("data", (b) => (run.out += b));
return run;
});
// The loser exits; the winner keeps running.
const [first] = await Promise.race(starts.map((r, i) => r.exit.then((e) => [i, e])));
const loser = starts[first];
const winner = starts[1 - first];
assert.equal(loser.child.exitCode, 3, loser.out);
assert.match(loser.out, new RegExp(`a bus host already runs for acme \\(pid ${winner.child.pid}\\)`));
await until(() => /bus host up: business acme/.test(winner.out), 30000);
assert.equal(winner.child.exitCode, null);
const state = readHostState(f.dataRoot);
assert.equal(state.pid, winner.child.pid);
assert.equal(state.live, true);
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, winner.child.pid);
const claims = () => readdirSync(hostDir(f.dataRoot)).filter((n) => n.startsWith("host.")).sort();
assert.deepEqual(claims(), ["host.json", "host.lock"], "no claim file left over");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await winner.exit)[0], 0, winner.out);
assert.deepEqual(claims(), [], "the stop gives back host.lock and host.json");
test("two starts racing for one organization leave exactly one host", async t => {
const f = await orgFixture(t);
const results = await Promise.allSettled([busStart(f.ctx, "acme", { detach: true }), busStart(f.ctx, "acme", { detach: true })]);
t.after(async () => { try { await busStop(f.ctx, "acme"); } catch {} });
assert.equal(results.filter(r => r.status === "fulfilled").length, 1);
assert.equal(results.filter(r => r.status === "rejected").length, 1);
assert.equal((await busStatus(f.ctx, "acme")).healthy, true);
assert.equal((await busStop(f.ctx, "acme")).stopped, true);
});
test("a host takes over a host.lock its dead host left, and puts back a live claim that replaced it meanwhile", (t) => {
@@ -338,46 +335,26 @@ test("a live host's lock stays, however old the file is", (t) => {
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
test("a host.lock left by a host killed with SIGKILL doesn't stop the next bus start", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const start = () => {
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"));
const run = { child, out: "", exit: once(child, "exit") };
child.stdout.on("data", (b) => (run.out += b));
child.stderr.on("data", (b) => (run.out += b));
return run;
};
const first = start();
await until(() => /bus host up: business acme/.test(first.out), 30000);
first.child.kill("SIGKILL");
await first.exit;
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, first.child.pid, "the killed host never gave back its lock");
// The orphaned broker sees its channel close and lets go of the writer lock.
test("a stale organization host lock is preserved and refuses another start", async t => {
const f = await orgFixture(t), receipt = await busStart(f.ctx, "acme", { detach: true });
process.kill(receipt.pid, "SIGKILL");
await until(() => startTimeOf(receipt.pid) === null, 30000);
await until(() => !existsSync(join(f.dataRoot, "bus", "writer.lock")), 30000);
const second = start();
await until(() => /bus host up: business acme/.test(second.out) || second.child.exitCode !== null, 30000);
assert.equal(second.child.exitCode, null, second.out);
assert.equal(readHostState(f.dataRoot).pid, second.child.pid);
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, second.child.pid);
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await second.exit)[0], 0, second.out);
const before = readFileSync(hostLock(f.dataRoot), "utf8");
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /stale.*lock/i);
assert.equal(readFileSync(hostLock(f.dataRoot), "utf8"), before);
});
test("while a host boots, a reader sees no host: the human commands still ask for --business, stop and status find none", (t) => {
const f = fixture(tmp(t));
test("while an organization host boots, stop and status refuse its unmatched lock", async t => {
const f = await orgFixture(t);
const booting = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business: "acme", startedAt: "2026-10-13T00:00:01.000Z", notifier: null };
claimHostLock(f.dataRoot, booting);
assert.equal(readHostState(f.dataRoot), null);
assert.equal(hostStatus(f.dataRoot).host, null);
await assert.rejects(busStatus(f.ctx, "acme"), /lock/i);
await assert.rejects(busStop(f.ctx, "acme"), /lock/i);
const inbox = spawnSync(process.execPath, [CLI, "inbox"], { env: f.env, encoding: "utf8", timeout: 20000 });
assert.notEqual(inbox.status, 0);
assert.match(inbox.stderr, /no bus host runs here, so name the business with --business/);
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 20000 });
assert.equal(stop.status, 0, stop.stderr);
assert.match(stop.stdout, /no host runs/);
assert.match(inbox.stderr, /name the organization/);
});
test("a host started before host.lock existed, holding only host.json, still refuses a second start, which leaves no lock", async (t) => {
@@ -761,77 +738,44 @@ test("bus stop refuses to signal a live pid that is not a bus host", async (t) =
assert.equal(child.signalCode, null, "the child was not signalled");
});
test("bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock", (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
test("a stale organization writer lock refuses start and status without removing it", async t => {
const f = await orgFixture(t);
mkdirSync(join(f.dataRoot, "bus"), { recursive: true, mode: 0o700 });
writeFileSync(join(f.dataRoot, "bus", "writer.lock"), JSON.stringify({ pid: 999999999, at: "2026-10-08T00:00:00Z" }), { mode: 0o600 });
const r = spawnSync(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /broker refused to start: startup-refused/);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
const s = spawnSync(process.execPath, [CLI, "bus", "status"], { env: f.env, encoding: "utf8" });
assert.equal(s.status, 0, s.stderr);
assert.match(s.stdout, /host: none/);
assert.match(s.stdout, /writer\.lock: pid 999999999 \(not running/);
const file = join(f.dataRoot, "bus", "writer.lock"), text = JSON.stringify({ pid: 999999999, at: "2026-10-08T00:00:00Z" });
writeFileSync(file, text, { mode: 0o600 });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /stale.*lock/i);
await assert.rejects(busStatus(f.ctx, "acme"), /stale.*lock/i);
assert.equal(readFileSync(file, "utf8"), text);
});
test("bus start refuses with exit 3 without a notifier config", (t) => {
const f = fixture(tmp(t));
const r = spawnSync(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(r.status, 3);
assert.match(r.stderr, /no notifier config/);
test("organization bus start refuses without a notifier config", async t => {
const f = await orgFixture(t, { notify: false });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
test("bus start runs until bus stop; status reports it while it runs", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
let out = "";
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (out += b));
await until(() => /bus host up: business acme/.test(out), 30000);
const status = spawnSync(process.execPath, [CLI, "bus", "status", "--json"], { env: f.env, encoding: "utf8" });
const s = JSON.parse(status.stdout);
assert.equal(s.host.live, true);
assert.equal(s.host.pid, child.pid);
assert.equal(s.host.notifier, null);
assert.equal(s.socket, true);
assert.equal(s.writerLock.live, true);
const exit = once(child, "exit");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.match(stop.stdout, new RegExp(`stopped bus host for acme \\(pid ${child.pid}\\)`));
assert.equal((await exit)[0], 0, out);
test("organization bus start, status and stop report the same live host", async t => {
const f = await orgFixture(t);
const started = await busStart(f.ctx, "acme", { detach: true });
t.after(async () => { try { await busStop(f.ctx, "acme"); } catch {} });
const status = await busStatus(f.ctx, "acme");
assert.equal(status.healthy, true); assert.equal(status.pid, started.pid);
assert.equal((await busStop(f.ctx, "acme")).stopped, true);
assert.equal((await busStatus(f.ctx, "acme")).healthy, false);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
const again = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8" });
assert.match(again.stdout, /no host runs/);
});
// Row 163 (mos-0961): the D2 rehearsal's host.
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(both.status, 4, both.stderr);
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
let out = "";
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (out += b));
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
test("organization bus --no-launch preserves the launch fence; --pm conflicts", async t => {
const f = await orgFixture(t);
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8" });
assert.equal(both.status, 4); assert.match(both.stderr, /--pm or --no-launch/);
const receipt = await busStart(f.ctx, "acme", { detach: true, noLaunch: true });
t.after(async () => { try { await busStop(f.ctx, "acme"); } catch {} });
assert.equal(readHostState(f.dataRoot).options.noLaunch, true);
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
const exit = once(child, "exit");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await exit)[0], 0, out);
assert.equal((await busStatus(f.ctx, "acme")).pid, receipt.pid);
assert.equal((await busStop(f.ctx, "acme")).stopped, true);
});
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
@@ -1029,20 +973,29 @@ test("bus-service.sh refuses a default claude without the execute bit, or in a P
});
// Row 191: a restarted halted instance must keep its recovery bus reachable.
test('bus start --pm while halted keeps the control host up without launching the chief', { timeout: 30000 }, async t => {
test('organization bus --pm while halted keeps the control host up', { timeout: 30000 }, async t => {
const { initializeControl, transition, readControl } = await import('../../processing-control/src/state.mjs');
const f = fixture(tmp(t)); notifyConfig(f.dataRoot, 'acme', null);
const f = await orgFixture(t);
initializeControl(f.dataRoot);
transition(f.dataRoot, { business: 'acme', human: 'jason', halted: true });
const child = spawn(process.execPath, [CLI, 'bus', 'start', 'acme', '--pm'], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const exited = once(child, 'exit'); let output = '';
child.stdout.on('data', b => { output += b; }); child.stderr.on('data', b => { output += b; });
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } });
await until(() => /processing halted: chief launch skipped/.test(output) || child.exitCode !== null);
assert.match(output, /processing halted: chief launch skipped/);
assert.equal(child.exitCode, null, output);
const status = spawnSync(process.execPath, [CLI, 'bus', 'status', '--json'], { env: f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(status.status, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).host.live, true);
const receipt = await busStart(f.ctx, 'acme', { detach: true, pm: true });
t.after(async () => { try { await busStop(f.ctx, 'acme'); } catch {} });
assert.match(readFileSync(join(f.dataRoot, 'bus-host/host.log'), 'utf8'), /processing halted: chief launch skipped/);
assert.equal((await busStatus(f.ctx, 'acme')).pid, receipt.pid);
assert.equal(readControl(f.dataRoot).halted, true);
assert.equal((await busStop(f.ctx, 'acme')).stopped, true);
});
test("organization notifier uses instance bindings and its own journal", async t => {
const f = await orgFixture(t), root = dirname(f.instanceRoot);
makeDeployment(root, { dmRecipient: IDS.owner });
const discord = await fakeDiscord(t);
const system = loadSystem({ env: f.env, instance: f.ctx.instance });
const boot = bootConfig({ system, businessId: "acme", orgRoot: f.dataRoot, env: f.env });
const host = await startHost({ boot, business: "acme", notifier: { binding: "test-seat", base: discord.base }, log: () => {} });
t.after(() => host.close(0));
assert.equal(host.dataRoot, f.dataRoot);
assert.ok(existsSync(journalPath(f.dataRoot, "acme")));
assert.equal(existsSync(journalPath(f.instanceRoot, "acme")), false);
await host.close(0);
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { humanEnv, notifyConfig } from './helpers.mjs';
import { busStart, busStatus, busStop } from '../src/org-bus.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('two organization buses are isolated and refusals never select a sibling', { timeout: 120000 }, async t => {
const home = mkdtempSync('/mnt/storage/scratch/tmp/s1-');
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
env.MOSAIC_ROLES_DIR = rolesCopy(home);
for (const id of ['alpha', 'beta']) {
const org = await reserveOrg(ctx, id);
await activateOrg(ctx, id);
writeJson(join(ctx.configDir, 'businesses', `${id}.json`), businessDoc(home, id));
notifyConfig(org.root, id, null);
}
t.after(async () => {
for (const id of ['alpha', 'beta']) { try { await busStop(ctx, id); } catch {} }
rmSync(home, { recursive: true, force: true });
});
const incompatible = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'start', 'alpha', '--pm', '--no-launch'], { env, encoding: 'utf8' });
assert.equal(incompatible.status, 4);
const start = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'start', 'alpha', '--no-launch', '--detach'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(start.status, 0, start.stderr);
const alpha = JSON.parse(start.stdout);
const beta = await busStart(ctx, 'beta', { detach: true, devLocalOwner: true });
assert.notEqual(alpha.socket, beta.socket);
assert.notEqual(alpha.root, beta.root);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
await assert.rejects(busStatus(ctx, 'missing'), e => e.code === 'org-unknown');
const launches = spawnSync(join(REPO, 'scripts/mosaic'), ['launches', 'list', '--business', 'beta', '--json'], { env, encoding: 'utf8' });
assert.equal(launches.status, 0, launches.stderr);
assert.ok(Array.isArray(JSON.parse(launches.stdout)));
const inbox = spawnSync(join(REPO, 'scripts/mosaic'), ['inbox', '--business', 'alpha', '--json'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(inbox.status, 3, inbox.stderr);
assert.match(inbox.stderr, /human-required/); // The existing pinned-tree proof refused after reaching alpha.
const status = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'status', 'alpha', '--json'], { env, encoding: 'utf8' });
assert.equal(status.status, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).socket, alpha.socket);
const restart = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'restart', 'alpha'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(restart.status, 0, restart.stderr);
assert.notEqual(JSON.parse(restart.stdout).pid, alpha.pid);
assert.equal(JSON.parse(readFileSync(join(alpha.root, 'bus-host/host.json'), 'utf8')).options.noLaunch, true);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'stop', 'alpha'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
// A forged alpha state cannot name the running beta host as alpha.
const alphaHost = join(alpha.root, 'bus-host');
const betaState = JSON.parse(readFileSync(join(beta.root, 'bus-host/host.json'), 'utf8'));
const forged = { ...betaState, business: 'alpha' };
writeFileSync(join(alphaHost, 'host.json'), JSON.stringify(forged), { mode: 0o600 });
writeFileSync(join(alphaHost, 'host.lock'), JSON.stringify(forged), { mode: 0o600 });
try { await assert.rejects(busStatus(ctx, 'alpha'), /mismatch|identity/i); }
finally { rmSync(join(alphaHost, 'host.json')); rmSync(join(alphaHost, 'host.lock')); }
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
mkdirSync(join(alpha.root, 'bus'), { recursive: true, mode: 0o700 });
writeFileSync(join(alpha.root, 'bus/writer.lock'), JSON.stringify({ pid: 99999999 }), { mode: 0o600 });
await assert.rejects(busStart(ctx, 'alpha', { detach: true }), /lock/i);
await assert.rejects(busStatus(ctx, 'alpha'), /lock/i);
writeFileSync(join(alpha.root, '.mosaic-org.json'), JSON.stringify({ instance: 's1test', id: 'beta' }), { mode: 0o600 });
await assert.rejects(busStatus(ctx, 'alpha'), e => e.code === 'org-mismatch');
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
});
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { get } from 'node:http';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
const home = mkdtempSync('/mnt/storage/scratch/tmp/s1-');
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha');
await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto' });
const url = new URL(result.url);
assert.equal(url.hostname, '127.0.0.1');
assert.ok(Number.isSafeInteger(result.pid));
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
assert.equal((await consoleStop(ctx)).stopped, true);
});
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
const home = mkdtempSync('/mnt/storage/scratch/tmp/s1-');
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
assert.equal(spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env }).status, 0);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const start = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(start.status, 0, start.stderr);
assert.match(JSON.parse(start.stdout).url, /^http:\/\/127\.0\.0\.1:\d+\/$/);
const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'stop'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { orgPaths } from '../src/config.mjs';
test('organization paths are rooted together and reject overlong sockets', () => {
const root = '/var/mosaic/org-runtime/alpha';
assert.deepEqual(orgPaths(root), {
socket: join(root, 'bus/broker.sock'), busDir: join(root, 'bus'),
runs: join(root, 'runs'), launches: join(root, 'launches'),
sessions: join(root, 'sessions'), workspaces: join(root, 'workspaces'),
threads: join(root, 'threads'), hostDir: join(root, 'bus-host'),
});
assert.throws(() => orgPaths('/' + 'a'.repeat(110)), /socket.*long/i);
assert.throws(() => orgPaths('relative'), /absolute/i);
});
import { bootConfig, loadSystem } from '../src/config.mjs';
import { fixture, tmp } from './helpers.mjs';
test('broker boot keeps instance and organization roots distinct', (t) => {
const f = fixture(tmp(t));
const root = join(f.dataRoot, 'org-runtime', 'acme');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', orgRoot: root, env: f.env });
assert.equal(boot.dataRoot, root);
assert.equal(boot.instanceRoot, f.dataRoot);
});
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { claimHostLock, hostLock, startTimeOf } from '../src/host.mjs';
test('organization host claim refuses a stale lock without removing it', t => {
const root = tmp(t);
mkdirSync(join(root, 'bus-host'), { mode: 0o700 });
const stale = { pid: 99999999, startTime: '0', business: 'alpha', startedAt: 'old' };
writeFileSync(hostLock(root), JSON.stringify(stale), { mode: 0o600 });
const mine = { pid: process.pid, startTime: startTimeOf(process.pid), business: 'alpha', startedAt: 'now' };
assert.throws(() => claimHostLock(root, mine, { refuseStale: true }), /stale.*lock/i);
assert.deepEqual(JSON.parse(readFileSync(hostLock(root), 'utf8')), stale);
});
import { busStatus } from '../src/org-bus.mjs';
test('an inaccessible registry refuses as a configuration error', async t => {
const root = tmp(t);
await assert.rejects(busStatus({ configDir: join(root, 'missing'), dataRoot: root, instance: 'test' }, 'alpha'),
error => error.exitCode === 3 && /registry/i.test(error.message));
});