Files
jason.woltje 23e504f331 fix(cli,bus,organization): E2E6 lane f6c, refusal lines name the next step (#1873)
1. Stale bus host lock refusals from bus status, start, stop and restart
   name mosaic bus recover ORG.
2. org definitions: unknown org, unknown or malformed name and bad id give
   one refusal or usage line, no stack trace, no host path.
3. Pack, board and workflow refusals print their hint as template save does;
   tested with the real CliError transport shape.
4. console start and stop clear a console/start.lock whose named supervisor
   (pid and start time) is gone; a live or unnamed holder refuses.
5. Template save keeps free text verbatim and writes no placeholders; the
   preview lists each field that mentions an org-specific name.
6. A request the shim never sent (no socket, connect refused) exits 5 and
   the CLI reports bus-not-running with the start and recover line.
7. autonomy-precondition carries the check that failed, including on the
   hire paths.
8. Saving under an existing template name refuses template-exists with a
   next step; refusal table entry added, channel-proof pins moved.
2026-11-13 20:14:21 -06:00

102 lines
7.1 KiB
JavaScript

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 { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/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(join(tmpdir(), '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);
const refused = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'recover', 'alpha'], { env, encoding: 'utf8' });
assert.equal(refused.status, 3, refused.stderr);
assert.equal((await busStatus(ctx, 'alpha')).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);
// Recovery removes dead host and broker locks, and a fresh start can take them.
const staleHost = { pid: 2147483647, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
writeFileSync(join(alpha.root, 'bus-host/host.json'), JSON.stringify(staleHost), { mode: 0o600 });
writeFileSync(join(alpha.root, 'bus-host/host.lock'), JSON.stringify(staleHost), { mode: 0o600 });
writeFileSync(join(alpha.root, 'bus/writer.lock'), JSON.stringify({ pid: 2147483647, at: '2026-01-01T00:00:00Z' }), { mode: 0o600 });
// E2E6 fix 1: status, start, stop and restart refuse a dead host's lock with the message unchanged and a
// next line naming the verb that clears it.
for (const args of [['status', 'alpha', '--json'], ['start', 'alpha', '--no-launch', '--detach'], ['stop', 'alpha'], ['restart', 'alpha']]) {
const r = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', ...args], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(r.status, 3, `${args[0]}: ${r.stderr}`);
assert.match(r.stderr, /^mosaic: stale bus host lock$/m, args[0]);
assert.match(r.stderr, /^mosaic: next: if the bus host died, clear it with mosaic bus recover alpha; recover refuses while the host still runs$/m, args[0]);
}
await assert.rejects(busStatus(ctx, 'alpha'), e => e.message === 'stale bus host lock' && e.exitCode === 3 && /mosaic bus recover alpha/.test(e.next));
const recovery = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'recover', 'alpha'], { env, encoding: 'utf8' });
assert.equal(recovery.status, 0, recovery.stderr);
const restarted = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
assert.ok(restarted.pid > 0);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
await busStop(ctx, 'alpha');
// 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);
});