import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import * as org from '../../organization/src/org-service.mjs'; import { listOrgs } from '../../organization/src/org-registry.mjs'; import { busStart, busStopForDelete } from '../src/org-bus.mjs'; import { readHostState } from '../src/host.mjs'; const repo = resolve(import.meta.dirname, '../../..'); async function until(fn) { const end = Date.now() + 20000; while (Date.now() < end) { if (await fn()) return; await new Promise(r => setTimeout(r, 20)); } throw Error('probe condition timed out'); } test('internal host holds lifecycle fence from admission through readiness', { timeout: 60000 }, async t => { const home = await mkdtemp(join(tmpdir(), 'r-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-rv/config.json') }; const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'rv'], { env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr); const ctx = { configDir: join(home, '.config/mosaic-rv'), dataRoot: join(home, '.mosaic-rv'), instance: 'rv', env, principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; await org.createOrg(ctx, 'alpha', await org.templateRender('alpha', { fixture: 'local' })); const gate = join(home, 'gate'), preload = join(home, 'pause.mjs'); await writeFile(preload, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const append=fs.appendFileSync;let once=false;fs.appendFileSync=function(file,...args){if(!once&&String(file).endsWith('/bus-host/boot-events.jsonl')){once=true;fs.writeFileSync(${JSON.stringify(gate+'.ready')},'ready');const end=Date.now()+15000;while(!fs.existsSync(${JSON.stringify(gate+'.go')})){if(Date.now()>end)throw Error('gate timed out');Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10);}}return append.call(this,file,...args)};syncBuiltinESMExports();`); const child = spawn(process.execPath, ['--import', preload, join(repo, 'packages/cli/src/cli.mjs'), 'bus', 'start', 'alpha', '--internal-host', '--dev-local-owner', '--no-launch'], { env, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); let output = '', ready = false; child.stdout.on('data', d => { output += d; }); child.stderr.on('data', d => { output += d; }); child.on('message', m => { if (m?.orgReady === 'alpha') ready = true; }); const ended = new Promise(r => child.once('exit', r)); t.after(async () => { await writeFile(gate+'.go', 'go'); if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await ended; } await rm(home, { recursive: true, force: true }); }); await until(async () => { try { await access(gate+'.ready'); return true; } catch { return child.exitCode !== null; } }); assert.equal(child.exitCode, null, output); assert.equal(readHostState(join(ctx.dataRoot, 'org-runtime', 'alpha')), null); await assert.rejects(org.archiveOrg(ctx, 'alpha', { ifRevision: 1, busStop: busStopForDelete }), { code: 'org-locked' }); assert.equal((await listOrgs(ctx))[0].state, 'active'); await writeFile(gate+'.go', 'go'); await until(() => ready || child.exitCode !== null || child.signalCode !== null); assert.equal(ready, true, output); assert.equal(readHostState(join(ctx.dataRoot, 'org-runtime', 'alpha'))?.live, true); await busStopForDelete(ctx, 'alpha'); await ended; const result = await org.archiveOrg(ctx, 'alpha', { ifRevision: 1, busStop: busStopForDelete }); assert.equal(result.outcome, 'done'); assert.equal((await listOrgs(ctx))[0].state, 'archived'); }); test('archive between parent preparation and child admission prevents the bus from starting', { timeout: 60000 }, async t => { const home = await mkdtemp(join(tmpdir(), 'r-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-rv/config.json') }; const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'rv'], { env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr); const ctx = { configDir: join(home, '.config/mosaic-rv'), dataRoot: join(home, '.mosaic-rv'), instance: 'rv', env, principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; await org.createOrg(ctx, 'alpha', await org.templateRender('alpha', { fixture: 'local' })); const gate = join(home, 'gate'), preload = join(home, 'pause.mjs'); await writeFile(preload, `import fs from 'node:fs';fs.writeFileSync(${JSON.stringify(gate+'.ready')},'ready');const end=Date.now()+15000;while(!fs.existsSync(${JSON.stringify(gate+'.go')})){if(Date.now()>end)throw Error('gate timed out');Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10);}`); env.NODE_OPTIONS = `--import=${preload}`; const pending = busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true }).then(value => ({ value }), error => ({ error })); try { await until(async () => { try { await access(gate+'.ready'); return true; } catch { return false; } }); const archived = await org.archiveOrg(ctx, 'alpha', { ifRevision: 1, busStop: busStopForDelete }); assert.equal(archived.outcome, 'done'); await writeFile(gate+'.go', 'go'); const result = await pending; assert.match(result.error?.message ?? '', /bus failed to start/); assert.equal((await listOrgs(ctx))[0].state, 'archived'); assert.equal(readHostState(join(ctx.dataRoot, 'org-runtime', 'alpha'))?.live ?? false, false); } finally { await writeFile(gate+'.go', 'go'); await pending; await rm(home, { recursive: true, force: true }); } });