Files
stack/packages/bus/tests/model-restart.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

159 lines
11 KiB
JavaScript

// B1 / 647: real runtime timer, scratch proxy and persisted sessions, never the host instance.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash, randomBytes } from 'node:crypto';
import { setTimeout as wait } from 'node:timers/promises';
import { startBroker } from '../src/runtime.mjs';
import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../organization/tests/helpers.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
async function fixture(t, { overdue = false, exited = false } = {}) {
const root = fs.mkdtempSync(join(tmpdir(), 'b1-'));
let server, credentials, child, childClosed, bus, launcher;
let completed = 0;
const originalSweep = ModelKeys.prototype.sweep;
const exit = async () => {
// Only the ChildProcess this fixture spawned: never use a registry-supplied PID.
if (child?.pid && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
if (childClosed) await childClosed;
};
t.after(async () => {
const errors = [];
for (const close of [() => launcher?.close(), () => bus?.close(), () => credentials?.close(), exit,
async () => { server?.closeAllConnections(); if (server?.listening) await new Promise(resolve => server.close(resolve)); }]) {
try { await close(); } catch (error) { errors.push(error); }
}
ModelKeys.prototype.sweep = originalSweep;
fs.rmSync(root, { recursive: true, force: true });
if (errors.length) throw new AggregateError(errors, 'scratch fixture cleanup failed');
});
// Await the ORIGINAL sweep, including its real HTTP/filesystem work. Count both
// fulfilled and rejected completions; the runtime still handles its own refusal.
ModelKeys.prototype.sweep = async function (...args) {
try { return await originalSweep.apply(this, args); }
finally { completed++; }
};
const observeSweep = async () => {
const before = completed, deadline = Date.now() + 5000;
// Same five-second bounded wait budget as bus/tests/host-run.test.mjs:47.
// Production interval remains 1000ms; no clock or scheduler is replaced.
while (completed <= before && Date.now() < deadline) await wait(20);
assert.ok(completed > before, 'original runtime sweep completed within five seconds');
};
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
fs.mkdirSync(dataRoot, { mode: 0o700 });
const config = systemConfig(root), system = systemFor(root), rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo'); doc.plugins = { ...doc.plugins, harness: ['pi'] };
writeJson(join(configDir, 'businesses', 'demo.json'), doc);
const business = loadBusiness('demo', { dir: configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
const storeRoot = join(configDir, 'secrets', 'agents'); fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
fs.writeFileSync(join(configDir, 'secrets', 'proxy-management.key'), 'fx647-manage', { mode: 0o600 });
const prefix = `msk-${id(configDir)}-`;
const state = { keys: [prefix + 'sentinel-' + randomBytes(32).toString('base64url')], put: 0, removed: 0 };
server = http.createServer(async (req, res) => {
if (req.url === '/v8/management/config/access/api-keys') {
if (req.headers.authorization !== 'Bearer fx647-manage') { res.writeHead(401); res.end('{}'); return; }
if (req.method === 'GET') { res.end(JSON.stringify(state.keys)); return; }
const chunks = []; for await (const chunk of req) chunks.push(chunk);
const next = JSON.parse(Buffer.concat(chunks));
state.removed += state.keys.filter(key => !next.includes(key)).length;
state.keys = next; state.put++; res.end(JSON.stringify({ status: 'ok' })); return;
}
res.writeHead(state.keys.includes(req.headers.authorization?.slice(7)) ? 200 : 401); res.end('{}');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
credentials = new Credentials({ storeRoot, dataRoot, modelAgents: ['demo/chief'] });
const manager = new ModelKeys({ credentials, configDir, baseUrl, client: new ManagementClient({ baseUrl, key: 'fx647-manage' }) });
await manager.reconcile(); const first = await manager.ensure('demo/chief'); await manager.rotate('demo/chief');
const record = credentials.modelRecord('demo/chief');
credentials.writeModel('demo/chief', { ...record, retiring: record.retiring.map(r => ({ ...r, retireAt: new Date(Date.now() + (overdue ? -60_000 : 3_600_000)).toISOString() })) });
credentials.close(); credentials = null; state.put = 0; state.removed = 0;
// A harmless scratch-owned process supplies real /proc identity. It is deliberately
// not a session runner, so stopSession must refuse rather than signal this stand-in.
child = spawn('/usr/bin/sleep', ['60'], { stdio: 'ignore' });
childClosed = new Promise(resolve => child.once('close', resolve));
await once(child, 'spawn');
const ticks = startTimeOf(child.pid); assert.equal(typeof ticks, 'string');
const entry = { business: 'demo', instance: 'chief', run: 'r647-old', harness: 'pi', provider: 'fake', model: 'fake',
pid: child.pid, startTime: ticks, runnerPid: child.pid, runnerStartTime: ticks, modelKeyId: id(first.key), launchedBy: 'chief' };
const writeSessions = entries => writeJson(sessionsFile(dataRoot), { sessionsVersion: SESSIONS_VERSION, sessions: entries });
writeSessions([entry]);
if (exited) await exit();
const boot = async () => {
bus = await startBroker({ dataRoot, businesses: { demo: busOrganization(business, resolved) }, organization: { dir: configDir, rolesDir, system },
proxy: { port: server.address().port, binary: '/fixture-unused' } });
return bus;
};
const noLaunch = async () => {
const ops = [];
const host = { business: 'demo', beforeClose() {}, bindLaunch: async r => { ops.push('bindLaunch'); return bus.bindLaunch(r); },
op: async r => { ops.push(r.op); throw Error('fixture-unrequested-op'); } };
launcher = createLauncher({ host, system: JSON.parse(fs.readFileSync(config, 'utf8')), launches: false, env: { MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir, PATH: '/usr/bin' }, log: () => {} });
await launcher.listen(); return ops;
};
const retained = () => {
assert.equal(state.keys.includes(first.key), true, 'old key stays remotely installed');
assert.equal(bus.credentials.modelRecord('demo/chief').retiring.some(r => r.keyId === entry.modelKeyId), true, 'retiring id stays locally recorded');
assert.equal(state.removed, 0, 'no remote key removal');
};
return { root, dataRoot, state, entry, writeSessions, boot, noLaunch, exit, retained, observeSweep,
alive: () => startTimeOf(child.pid) === ticks,
absent: () => { assert.equal(state.keys.includes(first.key), false); assert.equal(bus.credentials.modelRecord('demo/chief').retiring.length, 0); },
bind: () => bus.bindLaunch({ business: entry.business, role: entry.instance, run: entry.run, harness: entry.harness, pid: entry.pid, startTime: entry.startTime, modelKeyId: entry.modelKeyId }) };
}
test('B1 restart before grace discovers persisted old-key run without bindLaunch', async t => {
const f = await fixture(t); await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
test('B1 no-launch restart past grace neither signals nor ends a prior session', async t => {
const f = await fixture(t, { overdue: true }); const before = fs.readFileSync(sessionsFile(f.dataRoot), 'utf8');
await f.boot(); const ops = await f.noLaunch(); await f.observeSweep();
f.retained(); assert.equal(f.alive(), true); assert.equal(ops.includes('bindLaunch') || ops.includes('endLaunch'), false);
assert.equal(fs.readFileSync(sessionsFile(f.dataRoot), 'utf8'), before);
});
test('B1 normal recovery gap retains boot inventory when Registry clears before rebinding', async t => {
const f = await fixture(t); await f.boot(); new Registry(f.dataRoot); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
for (const corruption of ['malformed', 'missing']) test(`B1 ${corruption} persisted discovery never authorizes retirement`, async t => {
const f = await fixture(t, { overdue: true });
if (corruption === 'malformed') fs.writeFileSync(sessionsFile(f.dataRoot), '{'); else fs.unlinkSync(sessionsFile(f.dataRoot));
await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
for (const field of ['startTime', 'runnerStartTime', 'modelKeyId']) test(`B1 persisted identity disagreement in ${field} retains key and record`, async t => {
const f = await fixture(t, { overdue: true });
f.writeSessions([{ ...f.entry, [field]: field === 'modelKeyId' ? 'fedcba987654' : '0' }]);
await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
test('B1 discovery failure after boot retains the cached live old-key inventory', async t => {
const f = await fixture(t, { overdue: true }); await f.boot(); fs.writeFileSync(sessionsFile(f.dataRoot), '{');
await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
test('B1 bound run with unauthorized runner stop retains key and retiring record', async t => {
const f = await fixture(t, { overdue: true }); await f.boot(); f.bind(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
});
test('B1 confirmed persisted runner exit permits old-key retirement after grace', async t => {
const f = await fixture(t, { overdue: true, exited: true }); await f.boot(); await f.observeSweep(); f.absent();
});
test('B1 an observed persisted exit after restart permits retirement, never an empty registry alone', async t => {
const f = await fixture(t, { overdue: true }); await f.boot(); await f.observeSweep(); f.retained();
await f.exit(); await f.observeSweep(); f.absent();
});