Merge branch 'org/s1' into org/s5
# Conflicts: # scripts/mosaic
This commit is contained in:
commit
487a2e7fa0
18 files changed
+606
-208
No files matched your search
+98
-51
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 } : {}) });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user