diff --git a/BUILD-LOG.md b/BUILD-LOG.md index bb8d4000b..798014443 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6322,3 +6322,7 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 UTC | UI slice 6b — implementation phase | Before: ui-dev base 43686b145 shows unavailable project states for absent records and message correlation IDs with no lifecycle record, and the audit records several remaining presentation defects. The assignment and protected boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/brief-s6b.md. The queue refused this linked worktree. Verification and outcome are recorded in the following phase entry. 2026-11-06 UTC | UI slice 6b — implementation and verification | After: missing project records have a named refusal and empty presentation; message correlation IDs with no intake record no longer imply failed request reads, while real failures remain visible. Conversation, holder, tab, chip and Assistant sizing polish is included. Node 26 final gates: project 51 pass / 0 fail / 0 cancelled / 0 skipped; WebUI 786 / 0 / 0 / 0, bounded to four CPUs after two load-sensitive timeouts passed independently. The earlier full run and its failures, fixture screenshots and request-ID trace are in /mnt/storage/scratch/mosaic-lead/uidev/s6b-report.md. Queue refused the linked worktree; no push, merge or deployment. Local candidate for independent review. + +2026-11-07 UTC | Organizations S1 — implementation phase | Before: ui-dev c8d6d12fe used the instance root for one bus and the Console had no scoped lifecycle commands. The S1 stream charter and boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/org-release/PLAN.md. The queue refused this linked worktree; canonical checkout access was excluded. Verification and unresolved integration boundaries follow in the stream report. + +2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a525a669..b5d967e9f 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2111,3 +2111,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 UTC | Codex implementation sub-agent | #1858 legacy catalogue materialization guard on ui-providers-guard | Exact-base regression and Node 26 suite evidence recorded in /mnt/storage/scratch/mosaic-lead/uidev/providers-guard-report.md; queue refused linked worktree. Local candidate for independent review; no push or deployment. 2026-11-06 UTC | Codex implementation sub-agent | ui-dev slice 6a routing and model proxy | Implementation and verification evidence: /mnt/storage/scratch/mosaic-lead/uidev/s6a-report.md. Queue refused linked worktree; no push, merge or deployment. 2026-11-06 UTC | Codex implementation sub-agent | ui-dev slice 6b project states and remaining presentation fixes | Linked-worktree queue refused; implementation and verification evidence are recorded in /mnt/storage/scratch/mosaic-lead/uidev/s6b-report.md. Local candidate for independent review; no push or deployment. +2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment. diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index b7e5e5128..eac376975 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -246,6 +246,7 @@ function modelRunInventory(dataRoot, bound, credentials) { export async function startBroker({ dataRoot, + instanceRoot = null, businesses, launches = [], readers = [], @@ -805,7 +806,7 @@ export async function startBroker({ return proxyPage.request(verb, args, scope); }, proxyInit: organization ? async args => { - const settings = await initializeProxy({ configDir: organization.dir, dataRoot, ...args }); + const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args }); modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt }); configureProxyPage(); return settings; @@ -1080,10 +1081,12 @@ export async function startBroker({ } // One bounded queue read at a time. The service applies the configured // 1..59 second poll deadline; this tick only checks whether a read is due. - const usageTick = () => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); }; - usageTimer = setInterval(usageTick, 1000); - usageTimer.unref(); - usageTick(); // Optional telemetry never blocks the native ready answer. + if (instanceRoot === null) { + const usageTick = () => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); }; + usageTimer = setInterval(usageTick, 1000); + usageTimer.unref(); + usageTick(); // Optional telemetry never blocks the native ready answer. + } await requestLifecycle.recover(); requestTimer = setInterval(() => { const state = processing.read(); diff --git a/packages/bus/tests/org-runtime.test.mjs b/packages/bus/tests/org-runtime.test.mjs new file mode 100644 index 000000000..96ae0240d --- /dev/null +++ b/packages/bus/tests/org-runtime.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { telemetryFixture } from './p5-telemetry-fixture.mjs'; + +test('an organization bus does not drain the shared proxy usage queue', async t => { + const f = await telemetryFixture(t, { orgMode: true }); + await f.start(); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.equal(f.queueCalls(), 0); +}); diff --git a/packages/bus/tests/p5-telemetry-fixture.mjs b/packages/bus/tests/p5-telemetry-fixture.mjs index 840f8dc52..02188ed91 100644 --- a/packages/bus/tests/p5-telemetry-fixture.mjs +++ b/packages/bus/tests/p5-telemetry-fixture.mjs @@ -17,7 +17,7 @@ function deadline(promise, what, ms) { const expired = new Promise((_, reject) => { timer = realSetTimeout(() => reject(new Error(`p5 fixture: ${what} within ${ms} ms`)), ms); }); return Promise.race([promise, expired]).finally(() => realClearTimeout(timer)); } -export async function telemetryFixture(t, { foreign = false, hold = false, waitMs = WAIT_MS } = {}) { +export async function telemetryFixture(t, { foreign = false, hold = false, waitMs = WAIT_MS, orgMode = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'p5-telemetry-')), config = join(root,'config'), data = join(root,'data'); mkdirSync(config,{mode:0o700}); mkdirSync(data,{mode:0o700}); const rolesDir = rolesCopy(config), doc = businessDoc(config,'demo'); @@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM }); await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); let bus=null,startPromise=null,closePromise=null; - const start=()=>startPromise??=(startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, + const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value))); const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}}; const close=()=>bus?(closePromise??=bus.close()):Promise.resolve(); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 20eb7d2ab..4449a4c88 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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 ] mosaic resume [--business ] mosaic processing status [--business ] [--json] - mosaic stop + mosaic stop --business mosaic launches off|on [--business ] - mosaic launches list [--json] - mosaic bus start [--pm | --no-launch] [--dev-local-owner] - mosaic bus stop - mosaic bus status [--json]`; + mosaic launches list --business [--json] + mosaic bus start [--pm | --no-launch] [--dev-local-owner] [--detach] + mosaic bus stop|restart + mosaic bus status [--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`); diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index 3c84e3c2d..c571697e2 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -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)), "..", "..", // `/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; diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs index 69640f23c..8e851a01f 100644 --- a/packages/cli/src/host.mjs +++ b/packages/cli/src/host.mjs @@ -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"); diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index a0b0db8d1..27c1c5de5 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -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); diff --git a/packages/cli/src/notifier-process.mjs b/packages/cli/src/notifier-process.mjs index 58cc09b01..a88d670ca 100644 --- a/packages/cli/src/notifier-process.mjs +++ b/packages/cli/src/notifier-process.mjs @@ -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 } : {}) }); diff --git a/packages/cli/src/org-bus.mjs b/packages/cli/src/org-bus.mjs new file mode 100644 index 000000000..9bdf15f90 --- /dev/null +++ b/packages/cli/src/org-bus.mjs @@ -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 }); +} diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs new file mode 100644 index 000000000..811ba22e0 --- /dev/null +++ b/packages/cli/src/org-console.mjs @@ -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'); +} diff --git a/packages/cli/tests/cli.test.mjs b/packages/cli/tests/cli.test.mjs index 57f8ea22f..ec692e7d6 100644 --- a/packages/cli/tests/cli.test.mjs +++ b/packages/cli/tests/cli.test.mjs @@ -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); diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 378744c5b..10fd1e1c2 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -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); }); diff --git a/packages/cli/tests/org-bus.test.mjs b/packages/cli/tests/org-bus.test.mjs new file mode 100644 index 000000000..e5c166cd9 --- /dev/null +++ b/packages/cli/tests/org-bus.test.mjs @@ -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); +}); diff --git a/packages/cli/tests/org-console.test.mjs b/packages/cli/tests/org-console.test.mjs new file mode 100644 index 000000000..c0833e893 --- /dev/null +++ b/packages/cli/tests/org-console.test.mjs @@ -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); +}); diff --git a/packages/cli/tests/org-paths.test.mjs b/packages/cli/tests/org-paths.test.mjs new file mode 100644 index 000000000..ce1f18e3d --- /dev/null +++ b/packages/cli/tests/org-paths.test.mjs @@ -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)); +}); diff --git a/scripts/mosaic b/scripts/mosaic index ee9c5f4c2..1f2ae71c3 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -41,6 +41,6 @@ if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then exec node "$REPO/packages/library/src/survey.mjs" "$@" fi case "${1:-}" in - org|proxy|halt|resume|processing|usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|grants|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; + org|proxy|halt|resume|processing|usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|console|plugins|credentials|hosts|grants|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; esac exec node "$REPO/packages/seat/src/cli.mjs" "$@"