// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2, // the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and // T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in // stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and // ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { once } from "node:events"; import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { LiveSessionGuard } from "../../conversation/src/guard.mjs"; import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs"; import { hostStatus } from "../src/host.mjs"; import { businessDoc, rolesCopy, writeJson } from "../../organization/tests/helpers.mjs"; import { REPO_SKILLS } from "../../harness/tests/helpers.mjs"; import { humanEnv, notifyConfig, tmp } from "./helpers.mjs"; const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url)); const CONFIG = join(SCRIPTS, "mosaic-config.mjs"); const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh"); const BUS_SERVICE = join(SCRIPTS, "bus-service.sh"); const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh"); const STACK_COPY = join(SCRIPTS, "stack-copy.sh"); const MOSAIC = join(SCRIPTS, "mosaic"); // The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one. function homeEnv(home, extra = {}) { const env = humanEnv({ HOME: home, ...extra }); if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG; delete env.XDG_CONFIG_HOME; return env; } function run(cmd, args, env) { const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 }); return { code: r.status, out: r.stdout, err: r.stderr }; } // The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243, // sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it. const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home)); const bootstrap = (home, ...args) => underUmask("022", home, args); const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra)); const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json"); // The instance op's exports as an object. function exportsOf(out) { return Object.fromEntries(out.trim().split("\n").map((line) => { const m = line.match(/^(\w+)='(.*)'$/); assert.ok(m, `not a shell-safe export: ${line}`); return [m[1], m[2]]; })); } // Every entry under `dir`: relative path, type, mode, size, mtime and content hash. function snapshot(dir) { const out = {}; const walk = (d) => { for (const name of readdirSync(d).sort()) { const p = join(d, name); const s = lstatSync(p); const key = relative(dir, p); if (s.isDirectory()) { out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs }; walk(p); } else { out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") }; } } }; if (existsSync(dir)) walk(dir); return out; } const mode = (p) => statSync(p).mode & 0o777; test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => { await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => { const home = tmp(t, "si-"); for (const name of ["alpha", "beta"]) { const r = bootstrap(home, "--instance", name); assert.equal(r.code, 0, r.err); const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`); assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`); assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700); assert.equal(mode(cfg), 0o644); assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root); assert.equal(mode(root), 0o700); const marker = join(root, "instance.json"); assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`); assert.equal(mode(marker), 0o644); assert.deepEqual(readdirSync(root), ["instance.json"]); assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]); } assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false); assert.equal(existsSync(join(home, ".mosaic-dev")), false); }); await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => { const home = tmp(t, "si-"); const r = underUmask("077", home, ["--instance", "alpha"]); assert.equal(r.code, 0, r.err); assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700); assert.equal(mode(cfgOf(home, "alpha")), 0o600); assert.equal(mode(join(home, ".mosaic-alpha")), 0o700); assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600); }); await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => { const home = tmp(t, "si-"); for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); const before = snapshot(home); for (const name of ["alpha", "beta"]) { const r = bootstrap(home, "--instance", name); assert.equal(r.code, 0, r.err); assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`); } assert.deepEqual(snapshot(home), before); }); await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => { const home = tmp(t, "si-"); mkdirSync(join(home, ".mosaic-gamma")); writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n"); mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true }); mkdirSync(join(home, ".mosaic-delta")); writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`); writeFileSync(join(home, ".mosaic-delta", "other"), "x"); const before = snapshot(home); for (const name of ["gamma", "worktrees", "delta"]) { const r = bootstrap(home, "--instance", name); assert.equal(r.code, 3, r.err); // #1873: the data root by its role, never its path. assert.match(r.err, new RegExp(`instance-root-unclaimed: the instance's data root exists, is not empty and holds no instance.json naming ${name}; nothing was written`)); assert.ok(!r.err.includes(home), r.err); assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false); } assert.deepEqual(snapshot(home), before); // A root that is a file refuses the same way. writeFileSync(join(home, ".mosaic-file"), "x"); const r = bootstrap(home, "--instance", "file"); assert.equal(r.code, 3); assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/); assert.equal(existsSync(join(home, ".config", "mosaic-file")), false); }); await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => { const home = tmp(t, "si-"); mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 }); assert.equal(bootstrap(home, "--instance", "alpha").code, 0); assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`); rmSync(join(home, ".mosaic-alpha", "instance.json")); const before = snapshot(home); const r = bootstrap(home, "--instance", "alpha"); assert.equal(r.code, 3); assert.match(r.err, /instance-root-unclaimed: the data root holds no instance\.json naming alpha/); assert.ok(!r.err.includes(home), r.err); assert.deepEqual(snapshot(home), before); }); await t.test("a dangling symlink at the config path is never written through, named or default", (t) => { const home = tmp(t, "si-"); for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) { const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`); mkdirSync(dir, { recursive: true, mode: 0o700 }); symlinkSync(target, join(dir, "config.json")); const r = bootstrap(home, ...args); assert.equal(r.code, 2, `${name}: ${r.err}`); assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/); assert.equal(existsSync(target), false, `${name}: the link's target was created`); assert.deepEqual(readdirSync(dir), ["config.json"]); assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink()); } assert.deepEqual(readdirSync(home).sort(), [".config"]); }); await t.test("bad names and stray arguments exit 4 and write nothing", (t) => { const home = tmp(t, "si-"); for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) { const r = bootstrap(home, "--instance", name); assert.equal(r.code, 4, `${name}: ${r.err}`); assert.match(r.err, /instance-name-invalid/); } assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass"); rmSync(join(home, ".config"), { recursive: true }); rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true }); for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) { const r = bootstrap(home, ...args); assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`); assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/); } assert.deepEqual(readdirSync(home), []); }); await t.test("--instance dev writes exactly what no flag writes", (t) => { const plain = tmp(t, "si-"), dev = tmp(t, "si-"); const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev"); assert.equal(a.code, 0, a.err); assert.equal(b.code, 0, b.err); assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME")); const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }])); assert.deepEqual(shape(dev), shape(plain)); assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]); }); }); test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => { const home = tmp(t, "si-"); for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); const scratch = join(home, "scratch-config.json"); writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } }); await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => { const r = config(home, {}, "instance"); assert.equal(r.code, 0, r.err); assert.deepEqual(exportsOf(r.out), { MOSAIC_INSTANCE: "dev", MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"), MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"), MOSAIC_INSTANCE_DATA_ROOT: "", MOSAIC_UNIT_SUFFIX: "", }); assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out)); }); await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => { const r = config(home, { MOSAIC_CONFIG: scratch }, "instance"); assert.equal(r.code, 0, r.err); assert.deepEqual(exportsOf(r.out), { MOSAIC_INSTANCE: "dev", MOSAIC_INSTANCE_CONFIG: scratch, MOSAIC_INSTANCE_CONFIG_DIR: home, MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"), MOSAIC_UNIT_SUFFIX: "", }); }); await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => { for (const name of ["alpha", "beta"]) { const r = config(home, {}, "instance", "--instance", name); assert.equal(r.code, 0, r.err); assert.deepEqual(exportsOf(r.out), { MOSAIC_INSTANCE: name, MOSAIC_INSTANCE_CONFIG: cfgOf(home, name), MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`), MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`), MOSAIC_UNIT_SUFFIX: `-${name}`, }); } }); await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => { const link = join(home, "alpha-link.json"); symlinkSync(cfgOf(home, "alpha"), link); for (const given of [cfgOf(home, "alpha"), link]) { const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha"); assert.equal(r.code, 0, r.err); assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha")); } for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) { for (const op of ["instance", "validate", "env", "bootstrap"]) { const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha"); assert.equal(r.code, 4, `${op} ${given}: ${r.err}`); assert.equal(r.out, ""); assert.match(r.err, /instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file/); assert.ok(!r.err.includes(home) && !r.err.includes(given), r.err); } } }); await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => { const r = config(home, {}, "instance", "--instance", "gamma"); assert.equal(r.code, 3); // #1873: the refusal names the config by its instance, never by host path. assert.match(r.err, /configuration not found: the config of instance gamma \(run scripts\/bootstrap\.sh --instance gamma to create it\)/); assert.ok(!r.err.includes(cfgOf(home, "gamma")) && !r.err.includes(home), r.err); }); await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => { const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home)); assert.equal(first.code, 0, first.err); assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m")); assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out); assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out); const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") })); assert.equal(mismatch.code, 4); assert.equal(mismatch.out, ""); assert.match(mismatch.err, /instance-config-mismatch/); for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) { const r = run(BUS_SERVICE, args, homeEnv(home)); assert.equal(r.code, 4, args.join(" ")); assert.equal(r.out, ""); assert.match(r.err, /instance: usage: --instance NAME, given once/); } const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home)); assert.equal(bad.code, 4); assert.match(bad.err, /instance-name-invalid/); }); await t.test("copy takes no --instance in either script: every instance shares the copies", () => { const copies = join(home, "copies-probe"); const before = snapshot(home); for (const [cmd, args, err] of [ [STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/], [STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/], [WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/], [WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/], ]) { const r = run(cmd, args, homeEnv(home)); assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`); assert.equal(r.out, ""); assert.match(r.err, err, args.join(" ")); } assert.deepEqual(snapshot(home), before); }); }); test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => { const home = tmp(t, "si-"); for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot }); const scratch = join(home, "scratch-config.json"); const scratchRoot = join(home, "scratch-data"); writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } }); await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => { edit("alpha", join(home, ".mosaic-beta")); for (const op of ["instance", "validate", "env", "bootstrap"]) { const r = config(home, {}, op, "--instance", "alpha"); assert.equal(r.code, 3, `${op}: ${r.err}`); assert.equal(r.out, ""); assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance beta, not alpha/); assert.ok(!r.err.includes(home), r.err); } edit("alpha", join(home, ".mosaic-alpha")); assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0); }); await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => { for (const op of ["instance"]) { const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op); assert.equal(r.code, 3, r.err); assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance alpha; select it with --instance alpha/); assert.ok(!r.err.includes(home), r.err); } }); await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => { mkdirSync(scratchRoot, { recursive: true }); assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today"); writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`); const r = config(home, { MOSAIC_CONFIG: scratch }, "instance"); assert.equal(r.code, 3); assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/); writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`); assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0); rmSync(join(scratchRoot, "instance.json")); }); await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => { const marker = join(home, ".mosaic-alpha", "instance.json"); const good = readFileSync(marker, "utf8"); const cases = [ ["missing", () => {}, /holds no instance\.json naming alpha/], ["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/], ["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/], ["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/], ["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/], ["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/], ["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/], ]; for (const [name, change, why] of cases) { rmSync(marker, { recursive: true, force: true }); change(); const r = config(home, {}, "instance", "--instance", "alpha"); assert.equal(r.code, 3, `${name}: ${r.err}`); assert.match(r.err, /instance-root-unclaimed/, name); assert.match(r.err, why, name); rmSync(marker, { recursive: true, force: true }); writeFileSync(marker, good); } assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0); }); }); async function until(check, ms) { const end = Date.now() + ms; while (!check()) { if (Date.now() > end) throw new Error("timed out"); await new Promise((r) => setTimeout(r, 50)); } } // One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in // its config dir, its own roles copy, the skills its roles name, and a notifier with no binding. async function hostInstance(home, root, name) { assert.equal(bootstrap(home, "--instance", name).code, 0); const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`); const files = join(root, `${name}-files`); const roles = rolesCopy(files); const doc = businessDoc(files, "acme"); for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars }; writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } }); cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true }); const ctx = { configDir: join(home, ".config", `mosaic-${name}`), dataRoot, instance: name }; const org = await reserveOrg(ctx, "acme"); await activateOrg(ctx, "acme"); notifyConfig(org.root, "acme", null); return { name, cfg, dataRoot, orgRoot: org.root, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) }; } test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => { const root = tmp(t, "si-"); const home = join(root, "h"); mkdirSync(home); const both = await Promise.all(["alpha", "beta"].map((name) => hostInstance(home, root, name))); for (const inst of both) { const sock = join(inst.orgRoot, "bus", "broker.sock"); assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`); inst.sock = sock; inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] }); t.after(() => { run(MOSAIC, ["bus", "stop", "acme"], inst.env); if (inst.child.exitCode === null) inst.child.kill("SIGTERM"); }); inst.out = ""; inst.child.stdout.on("data", (b) => (inst.out += b)); inst.child.stderr.on("data", (b) => (inst.out += b)); } for (const inst of both) await until(() => inst.out.includes('"org":"acme"') || inst.child.exitCode !== null, 30000); for (const inst of both) { const receipt = JSON.parse(inst.out); assert.equal(receipt.org, "acme"); assert.equal(receipt.socket, inst.sock); inst.hostPid = receipt.pid; assert.ok(lstatSync(inst.sock).isSocket()); const r = run(MOSAIC, ["bus", "status", "acme", "--json"], inst.env); assert.equal(r.code, 0, r.err); const status = JSON.parse(r.out); assert.equal(status.org, "acme"); assert.equal(status.instance, inst.name); assert.equal(status.pid, inst.hostPid); assert.equal(status.healthy, true); const state = hostStatus(inst.orgRoot); assert.equal(state.writerLock.live, true); inst.lockPid = state.writerLock.pid; } assert.notEqual(both[0].hostPid, both[1].hostPid); assert.notEqual(both[0].lockPid, both[1].lockPid); const [alpha, beta] = both; const exitA = once(alpha.child, "exit"); const stopA = run(MOSAIC, ["bus", "stop", "acme"], alpha.env); assert.equal(stopA.code, 0, stopA.err); assert.equal(JSON.parse(stopA.out).stopped, true); assert.equal((await exitA)[0], 0, alpha.out); assert.equal(beta.child.exitCode, null); const still = JSON.parse(run(MOSAIC, ["bus", "status", "acme", "--json"], beta.env).out); assert.equal(still.pid, beta.hostPid); assert.equal(still.healthy, true); assert.equal(hostStatus(beta.orgRoot).writerLock.pid, beta.lockPid); assert.equal(existsSync(join(alpha.orgRoot, "bus", "writer.lock")), false); const exitB = once(beta.child, "exit"); assert.equal(run(MOSAIC, ["bus", "stop", "acme"], beta.env).code, 0); assert.equal((await exitB)[0], 0, beta.out); }); test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => { const root = tmp(t, "si-"); const home = join(root, "h"); mkdirSync(home); for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); // alpha's root moved by its operator; beta's config is broken. const moved = join(root, "alpha-data"); mkdirSync(moved); writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved }); writeFileSync(cfgOf(home, "beta"), "{ not json"); for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir)); const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] }); const refuses = [ [join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"], [join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"], [join(moved, "sessions", "x"), "stack instance alpha's configured data root"], [join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"], [join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"], [join(home, ".config"), "stack instance alpha's config dir"], ]; for (const [p, why] of refuses) { assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p); } for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) { assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir); } assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true); });