fix: close project archive admission races (#797)

This commit is contained in:
jason.woltje committed 2026-11-06 02:54:04 -06:00
1 parent 1c33c342bc
commit f23a998eed
23 files changed
+493 -17

No files matched your search

+2
View File
@@ -6198,3 +6198,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa
2026-11-06 | #797 MVP3 project archive round 2 — author verification complete | Archive admission now spans queued thread/session execution, product/repository intake, PRD activation, task requirements, bootstrap effects and request-bound manifests/continuations; accepted results, task/review/delivery receipts, cancellation, release/end and history remain usable. Archived navigation exposes saved threads/room history and Unarchive with only the two archive CSS hooks. Final full bus 1618/1618; WebUI 583/583; other path consumers 230/230; final affected lifecycle/launch files 67/67. CLI path gate 585/587: two dependency copy-fixture failures reproduced serially, then whole case2-install file 28/28 after removing an accidental yaml/yaml self-link; all other 31 files passed. Every earlier failed run and correction retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/. Store/schema remain 3t; cross-lane budget-supervisor correction deferred as directed. Local candidate for independent review only; no push, merge, deployment, delete, role/contract or fleet changes. Report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md.
2026-11-06 | #797 MVP3 project archive round 3 — implementation and verification | Before: r2 head 1c33c342b had two P1s: stale final launch admission and an omitted origin-chat binding downstream. After: a single broker snapshot fences thread and manifest at launch; shared complete request-binding checks fence bootstrap effects, PRD activation and task/requirements admission while preserving accepted receipts and reads. Added permanent race/origin/shared-repository regressions and operator guidance. Node 26: exact-parent negative 5 behavioral failures / 1 control pass; final regressions 6/6, bus excluding socket file 1614/1614, WebUI 583/583, corrected whole release file 226/226. The separate project-files socket test timed out after five passes (exit 124, one cancelled wrapper); C8 had 569/587 after fixture timeouts were fixed, with 17 release-fixture failures subsequently corrected and the known parent adapter-exited-127 failure remaining. Initial failures, full commands and comparisons are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-evidence/ and the report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. No schema/store, supervisor, role, contract or policy changes; no push, merge, deployment or live acceptance. Independent limited re-review remains.
+1
View File
@@ -2066,3 +2066,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action.
2026-11-06 | mvp3-archive-child (#797 lane) | feat/mvp3-project-archive from 05fdfef9e: owner-only project.archive/unarchive (schema 3s→3t project_archives journal), project.list includeArchived, archived-project refusals on thread/room sends, Console archive UI (new project-archive files), CLI project archive/unarchive/list --archived, restore-guard lists | bus 1601/1601 (Node 24; Node 26 pre-existing socket stall), webui 582/582, plugins 132/132, cli 1191/1194 with two pre-existing load flakes (row253 C4 timing, land_work; both pass isolated, base full-suite fails four of the same family); committed locally, not pushed; report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r1-report.md
2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action.
2026-11-06 | mvp3-archive-r3-implementer (#797 lane) | feat/mvp3-project-archive-r3 from 1c33c342b: fix final launch admission and preserve originating chat through bootstrap, PRD and task admissions; shared-repository note and regressions | Node 26: regressions 6/6, bus excluding socket 1614/1614, WebUI 583/583, corrected whole release file 226/226; socket timeout and known adapter-127 failure remain ungreen, with all intermediate failures retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. Local commit only; limited independent review pending; no push, merge or deployment.
+1 -1
View File
@@ -242,7 +242,7 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args, or `{includeArchived:true}`; human, tester and user sessions. The business's projects, oldest first, with archived projects hidden unless `includeArchived` asks for them; each returned archived project carries `archived:true`. A user still lists granted projects only. |
| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new work (`project-archived` from thread create/open/send, new session clears and thread run admission, room open/send, and product-bound request/PRD/task admission). Repository/product keys resolve to the chat archive journal too. Queued opens and clear successors are rechecked at launch and claim. Existing results, task completion receipts, cancellation, release/end and history stay usable; metadata-only session creation cannot execute while archived. |
| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new work (`project-archived` from thread create/open/send, new session clears and thread run admission, room open/send, and product-bound request/PRD/task admission). Repository/product keys resolve to the chat archive journal too: an archived chat binding fences repository admission for all chat projects naming that repository, and every archived binding must be unarchived to restore it. Queued opens and clear successors are rechecked at launch and claim. Existing results, task completion receipts, cancellation, release/end and history stay usable; metadata-only session creation cannot execute while archived. |
| `project.unarchive` | `{project}`; human only (#797 MVP3). Restores the project completely; a project that is not archived refuses `project-not-archived`. Returns `{project,archived:false}`. |
| `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. |
| `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. |
+9 -1
View File
@@ -447,6 +447,14 @@ export function projectArchived(store, business, project) {
business, project, project);
}
// All bindings that can authorize new work for a request, including its original chat.
export function requestArchived(state, services, extra = []) {
const plans = state.selection?.bindings ?? [];
return [state.project, state.chat_project, state.thread && services.threadProject?.(state.business, state.thread),
...plans.flatMap(({ plan }) => [plan.project, plan.chat_project, plan.source?.project]), ...extra]
.some(project => project && services.projectArchived?.(state.business, project));
}
export class Broker {
#store;
#requests;
@@ -892,7 +900,7 @@ export class Broker {
const chat = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', state.chat_project, s.business);
if (!chat || chat.repo !== state.project) fail('request-project-refused');
}
if (this.#archived(s.business, state.project) || this.#archived(s.business, state.chat_project)) fail('project-archived');
if (requestArchived(state, { projectArchived: (business, project) => this.#archived(business, project), threadProject: (business, thread) => this.#store.get('SELECT project FROM threads WHERE business=? AND id=?', business, thread)?.project })) fail('project-archived');
const board = bindings[0].plan.tracker.project;
if (args.project !== undefined && args.project !== board) fail('request-tracker-refused');
return board;
+1 -1
View File
@@ -87,7 +87,7 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
if (m.op === 'modelStall') return runtime.modelStall(m);
+2 -1
View File
@@ -1,3 +1,4 @@
import { requestArchived } from './broker.mjs';
// Lifecycle Task 2 (#797 / Gitea #1839): the host executor behind a confirmed new-project selection.
//
// prepareBootstrap builds the immutable resource plan the owner sees. executeBootstrap consumes exactly that
@@ -130,7 +131,7 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
const product = dirname(here.root), marker = markerFile({ dataRoot, business, project });
const chatId = chatProjectId(request, planDigest), human = () => writer.read(business).document.human;
const active = () => {
if ([project, plan.source.project, chatId].some(p => services.projectArchived?.(business, p))) refuse('project-archived');
if (requestArchived(journal.readRequest(business, request), services, [project, plan.source.project, plan.chat_project, chatId])) refuse('project-archived');
};
const uncertain = () => { throw new Uncertain('bootstrap-uncertain'); };
const markerOwns = () => {
+2 -3
View File
@@ -1,11 +1,10 @@
import { requestArchived } from './broker.mjs';
// Host-only fixed-stage continuations. Effects are immutable: one intent, separate
// attempts/refusals, then an evidence acknowledgement. Reading a message is never progress.
import { digest, refuse } from './request-store.mjs';
export function createContinuations({ journal, services }) {
const archived = state => [state.project, state.chat_project, state.thread && services.threadProject?.(state.business, state.thread),
...(state.selection?.bindings ?? []).flatMap(({ plan }) => [plan.project, plan.chat_project, plan.source?.project])]
.some(project => project && services.projectArchived?.(state.business, project));
const archived = state => requestArchived(state, services);
const contextFor = (state, target) => {
const context = services.continuationContext(state, target);
const admission = services.requestAdmission?.(state) ?? null;
+2 -2
View File
@@ -1,3 +1,4 @@
import { requestArchived } from './broker.mjs';
// Host controller for pre-task intake. Task 1 prepares and records selections only. Task 3 (#1839) adds the
// PM's question and the owner's Console answer, existing-project pick and cancellation. The owner's mode change is Task 4's
// modeConsole, which moves the project's mode revision and every request attached to it.
@@ -21,8 +22,7 @@ export function createRequestLifecycle({ journal, services }) {
return job.finally(() => { if (pending.get(k) === job) pending.delete(k); });
};
const active = (state, ...projects) => {
const origin = state.thread && services.threadProject?.(state.business, state.thread);
if ([state.project, state.chat_project, origin, ...projects].some(p => p && services.projectArchived?.(state.business, p))) refuse('project-archived');
if (requestArchived(state, services, projects)) refuse('project-archived');
};
const stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found');
const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null });
+4 -3
View File
@@ -1,6 +1,7 @@
import { requestArchived } from './broker.mjs';
// Host-only admission. The journal's readCandidate/authority/approval adapters read current host state.
import { exact, id, key, text, digest, refuse } from './request-store.mjs';
export function createRequirementCatalog({ journal, readSpec, builtinBinding, projectArchived }) {
export function createRequirementCatalog({ journal, readSpec, builtinBinding, projectArchived, threadProject }) {
const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request,
expectedRevision: state.revision, kind, body, actor: { role: null, run: null } });
const eventFor = snap => journal.snapshotEvents(snap.business, snap.project).filter(e => e.snapshot === snap.id).at(-1);
@@ -52,7 +53,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding, pr
const binding = journal.submission(state.business, state.request, args.decision) ?? refuse('prd-candidate-required');
const old = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (old) return structuredClone(journal.snapshot(old.result.snapshot));
if (projectArchived?.(state.business, state.project)) refuse('project-archived');
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
if (state.phase !== 'prd-review' || state.wait?.subject !== args.decision || digest(state.candidate) !== digest(binding.candidate)) refuse('prd-admission-revoked');
const authority = journal.authority(state, binding), approval = journal.approval(state, binding);
const material = await journal.readCandidate(binding.candidate, state.business);
@@ -62,7 +63,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding, pr
return journal.transaction(() => {
const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (prior) return structuredClone(journal.snapshot(prior.result.snapshot));
if (projectArchived?.(state.business, state.project)) refuse('project-archived');
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
const current = journal.readRequest(state.business, state.request);
if (current.revision !== state.revision || current.wait?.subject !== args.decision || journal.projectState(state.business, state.project)?.revision !== binding.candidate.mode_revision) refuse('prd-admission-revoked');
if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked');
+17 -3
View File
@@ -566,7 +566,21 @@ export async function startBroker({
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
function admission({ business: id, project }) {
function admission({ business: id, project, projects, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
if (thread != null) {
const row = broker.threadsState(id).find(r => r.thread === thread && r.session === session);
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
}
return { ready: true, revision: null, code: null };
}
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown');
if (projectArchived(store, id, project)) return no('project-archived');
@@ -595,11 +609,11 @@ export async function startBroker({
};
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived });
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived, threadProject: services.threadProject });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }) }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived,
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, threadProject: services.threadProject,
chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } }));
},
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
@@ -0,0 +1,104 @@
// Lifecycle Task 2 (#797 / Gitea #1839): a confirmed new-project selection becomes a usable project.
// Real startBroker over a real organization file, a real git factory repo and a fixture data root.
// The test barrier stands in for a kill: it throws after a step's effect and before its receipt.
import test from 'node:test';
import assert from 'node:assert/strict';
import cp, { execFileSync } from 'node:child_process';
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'Factory', GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Factory', GIT_COMMITTER_EMAIL: '[email protected]' };
const git = (cwd, ...args) => execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8' }).trim();
const PROJECT_FILE = { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'git.workingBranch': 'main' } };
// The factory: the declared `stack` project is a git repository with a project file.
function factory(root) {
const dir = join(root, 'project');
mkdirSync(join(dir, '.mosaic'), { recursive: true });
writeFileSync(join(dir, '.mosaic/project.json'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
writeFileSync(join(dir, 'README.md'), 'factory\n');
git(dir, 'init', '-q', '-b', 'main'); git(dir, 'add', '-A'); git(dir, 'commit', '-q', '-m', 'factory');
return dir;
}
// `defaultServices` boots with nothing injected, so the host builds its own preparation and admission; `withTrackers` forwards the
// boot config's tracker settings as process.mjs does (the adapter itself is not built).
async function setup(t, { barrier, configureFactory = () => {}, beforePropose = () => {}, defaultServices = false, withTrackers = defaultServices } = {}) {
const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo',d=>{d.roles.assistant={definition:'assistant'};return d;}), source = factory(root);
configureFactory({ root, f, source });
const hooks = { barrier };
const system = loadSystem({ env: f.env });
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
const plans = (dataRoot, ready = () => true) => ({
preparePlans: (state, args, from) => Promise.all(['autonomous', 'guided'].map(mode => prepareBootstrap({
business: writer.read('demo').document, request: state.request, sourceProject: args.source_project, project: args.project, name: args.name,
mode, dataRoot, organizationRevision: writer.read('demo').revision, tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' },
intent: { text: from.text, source_message: from.source_message } }))),
admitPlan: plan => plan.organization_revision === writer.read('demo').revision && ready() ? { ready: true } : { ready: false, code: 'organization-stale' },
barrier: step => hooks.barrier?.(step),
});
const boot = async () => {
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) })));
const bus = await startBroker({ ...config, ...(withTrackers ? { trackers } : {}), requestServices: defaultServices ? {} : plans(f.dataRoot) });
t.after(() => bus.close().catch(() => {}));
return { bus, config, trackers, warnings };
};
const first = await boot();
const caps = { human: first.bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
for (const role of ['pm', 'cto', 'coder', 'chief']) {
caps[role] = first.bus.broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
first.bus.broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args });
const originProject=call('human','project.create',{name:'Origin intake'}).id;
const thread=call('human','thread.create',{project:originProject,name:'Intake'}).id;
caps.assistant=first.bus.broker.bindLaunch({business:'demo',role:'assistant',run:'assistant-run',harness:'pi',thread});
call('assistant','role.claim');
const sent=call('human','thread.send',{thread,body:INTENT});
const relay=call('assistant','message.send',{to:'chief',in_reply_to:sent.id,body:'Original request '+sent.request});
call('chief','message.send',{to:'pm',in_reply_to:relay.id,body:'Open original request '+sent.request});
const opened = await call('pm', 'request.open', { request: sent.request });
// A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back.
let proposed = null, proposeError = null;
beforePropose({ root, f, source });
try {
proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack',
project: 'timer', name: 'Timer', tracker_project: 2 });
} catch (e) { proposeError = e; }
const choose = async (choice = 'guided') => {
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
return first.bus.requestLifecycle.resume('demo', sent.request);
};
const paths = { code: join(f.dataRoot, 'products/demo/timer/code'), records: join(f.dataRoot, 'projects/demo/timer'), product: join(f.dataRoot, 'products/demo/timer') };
const changes = () => { try { return readFileSync(writer.logFile('demo'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); } catch { return []; } };
const additions = () => changes().filter(c => c.state === 'committed' && c.detail?.verb === 'project.add');
return { originProject, thread, root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, proposeError, choose, paths, changes, additions,
rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) };
}
test('archiving the originating chat during bootstrap must fence the next effect',async t=>{
const f=await setup(t,{barrier: step=>{
if(step==='reserve')f.call('human','project.archive',{project:f.originProject});
}});
const state=await f.choose();
console.log('ORIGIN_BOOTSTRAP',JSON.stringify({phase:state.phase,wait:state.wait,codeExists:existsSync(f.paths.code),additions:f.additions().length}));
assert.equal(state.wait?.code,'project-archived');
assert.equal(existsSync(f.paths.code),false);
assert.equal(f.additions().length,0);
});
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
function archiveOrigin(f) {
const state=f.journal.readRequest('demo',f.request);
const p=f.store.get('SELECT project FROM threads WHERE id=?',state.thread).project;
f.call('human','project.archive',{project:p});
return p;
}
test('origin archive must fence later PRD activation', async t=>{
const f=await requestFixture(t,'autonomous',null,true);
const sub=await f.submit(); f.approve(sub); archiveOrigin(f);
const before=f.journal.snapshotEvents('demo','timer').length;
let value=null,error=null;
try {value=await f.activate(sub);}catch(e){error=e.code;}
console.log('ORIGIN_ARCHIVE_ACTIVATION',JSON.stringify({error,snapshot:value?.id,phase:f.journal.readRequest('demo',f.request).phase}));
assert.equal(error,'project-archived');
assert.equal(value,null); assert.equal(f.journal.snapshotEvents('demo','timer').length,before);
assert.equal(f.journal.readRequest('demo',f.request).phase,'prd-review');
});
test('origin archive must fence task and requirement admission', async t=>{
const f=await requestFixture(t,'autonomous',null,true);
const sub=await f.submit();f.approve(sub);const snap=await f.activate(sub);archiveOrigin(f);
const args={request:f.request,project_key:'timer',requirement_snapshot:snap.id,op:'new-after-origin-archive',requirement:'REQ-POMO-1'};
assert.throws(() => f.bus.broker.taskCreateTarget(f.caps.pm,args), e => e.code === 'project-archived');
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm,args), e => e.code === 'project-archived');
});
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
test('any archived chat binding fences a shared repository until all are unarchived',async t=>{
const f=await requestFixture(t);
const a=f.call('human','project.create',{name:'Factory one',repo:'stack'}).id;
const b=f.call('human','project.create',{name:'Factory two',repo:'stack'}).id;
const ready=()=>f.bus.admission({business:'demo',project:'stack'});
assert.equal(ready().ready,true);
f.call('human','project.archive',{project:a});assert.equal(ready().code,'project-archived');
assert.equal(f.bus.admission({business:'demo',project:'timer'}).ready,true);
f.call('human','project.archive',{project:b});
f.call('human','project.unarchive',{project:a});assert.equal(ready().code,'project-archived');
f.call('human','project.unarchive',{project:b});assert.equal(ready().ready,true);
console.log('SHARED_REPO_CONTROL',JSON.stringify({oneArchived:'project-archived',oneStillArchived:'project-archived',allLive:ready(),unrelated:f.bus.admission({business:'demo',project:'timer'})}));
});
+4
View File
@@ -711,6 +711,10 @@ export function createLauncher({
const ready = await host.op({ op: "request.admission", business: b.id, project });
if (ready?.ready !== true) throw new Refusal(ready?.code ?? "project-pending");
}
// One synchronous broker snapshot covers the thread and the entire manifest after every
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
try {
@@ -91,6 +91,7 @@ async function start(t, { f, adapter }) {
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
'request.admission': m => runtime.admission(m),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
+1
View File
@@ -22,6 +22,7 @@ const system = loadSystem();
const runtime = await startBroker(bootConfig({ system, businessId: business }));
const hooks = [], cleanups = [];
const ops = {
'request.admission': m => runtime.admission(m),
identity: m => runtime.identity(m.cap),
authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance),
refuse: m => runtime.refuse(m.cap, m.code, m.subject),
@@ -69,6 +69,7 @@ async function start(t, { f, adapter }, threadIdle) {
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
'request.admission': m => runtime.admission(m),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
@@ -0,0 +1,201 @@
import cp from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
import { requestHostOps } from './request-host-ops.mjs';
// Thread sessions (row 121, #1615): the launcher starts a thread instance's
// session when its thread has a pending message or was opened, stops it once
// the thread is idle, and gives every run of a thread the thread's own store.
// Real runners in the bwrap sandbox; a stub adapter keeps a history in the
// store and answers with what it found there.
//
// The host is in process: startBroker on bootConfig's config, with the ops
// the broker child forwards (packages/bus/src/process.mjs). The real host
// proves a human CLI process for every human verb, which a test run under an
// agent can't, so the test's human capability comes from bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from '../../business/tests/helpers.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { Client } from '../../bus/src/client.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { Store } from '../../bus/src/store.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { sandboxProblem } from '../../seat/src/sandbox.mjs';
import { launchLogFile, readSessions } from '../../seat/src/session.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { startTimeOf } from '../src/host.mjs';
import { createLauncher } from '../src/launcher.mjs';
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from './helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL('./fixtures/thread-adapter.mjs', import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
const root = tmp(t, "mosaic-thread-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return edit(doc);
});
withoutHostCredentials(f.env, { piKey: true });
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. close() runs the launcher's
// close hook, then closes the broker, as the real host does. `ops` replaces
// launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
...requestHostOps(runtime),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const threadRuns = (f, thread) => readSessions(f.dataRoot).filter((s) => s.thread === thread);
// Send to a thread and wait for the assistant's RESULT reply in that thread.
async function ask(call, thread, body) {
const sent = call("thread.send", { thread, body });
const reply = await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(reply.from_role, "assistant");
assert.equal(reply.to_role, "human");
return { sent, reply, saw: JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]) };
}
// The run stops on its own once the thread is idle: no session, no holder.
async function idleStop(f, host, thread, run) {
await until(() => launchLog(f).find((e) => e.event === "end" && e.run === run));
assert.ok(launchLog(f).find((e) => e.event === "thread-idle" && e.run === run && e.thread === thread));
assert.deepEqual(threadRuns(f, thread), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === thread).holder, null);
}
test('archive during final project admission must still prevent thread runner spawn', {skip,timeout:90000},async t=>{
const prepared=prepare(t),{f}=prepared;
let project,reads=0,spawned=0,archived=false;
const h=await start(t,prepared,{tickMs:60000,ops:runtime=>({
'request.admission':m=>{
const answer=runtime.admission(m);
if(project&&m.project==='stack'&&++reads===2){
const cap=runtime.broker.bindHuman({business:'acme',human:f.doc.human,via:'cli',outsideAgent:true});
runtime.broker.request(cap,{verb:'project.archive',args:{project}});archived=true;
}
return answer;
}
})});
project=h.call('project.create',{name:'intake without repo'}).id;
const thread=h.call('thread.create',{project,name:'queued'}).id;
h.call('thread.open',{thread});
const original=cp.spawn;
cp.spawn=(command,args,opts)=>{
if(opts?.cwd?.startsWith(join(f.dataRoot,'launches')))spawned++;
return original(command,args,opts);
};
syncBuiltinESMExports();
try {await h.launcher.tick();}finally{cp.spawn=original;syncBuiltinESMExports();}
console.log('FINAL_ADMISSION_ARCHIVE',JSON.stringify({reads,archived,spawned,logs:launchLog(f)}));
assert.equal(archived,true);assert.equal(spawned,0);
assert.equal(h.runtime.threadsState('acme').find(r => r.thread === thread).holder, null);
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
try { assert.equal(db.prepare("SELECT count(*) AS n FROM thread_claims WHERE thread=? AND op='claim'").get(thread).n, 0); } finally { db.close(); }
});
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import cp, { spawnSync } from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixture, tmp, withoutHostCredentials, fakeHarnesses } from './helpers.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { startHost, startTimeOf } from '../src/host.mjs';
import { createLauncher } from '../src/launcher.mjs';
import { Client } from '../../bus/src/client.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
async function setup(t, edit = d => d, { revoked = false } = {}) {
const root = tmp(t, 'request-launch-'), f = fixture(root, 'demo', edit);
withoutHostCredentials(f.env, { piKey: true });
const repo = f.doc.projects.stack.root;
for (const args of [['init','-qb','main'],['-c','user.name=test','-c','[email protected]','commit','--allow-empty','-qm','seed']]) {
const r = spawnSync('git', ['-C', repo, ...args], { env: gitEnv(), encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr);
}
const fake = fileURLToPath(new URL('../../harness/tests/fixtures/fake-adapter.mjs', import.meta.url));
const adapter = join(root, 'adapter.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${fake}'\n`);
const makeLoader = await fakeHarnesses(t, { pi: adapter, 'claude-code': adapter });
const system = loadSystem({ env: f.env }), host = await startHost({ boot: bootConfig({ system, businessId: 'demo', env: f.env }), business: 'demo' });
const launcher = createLauncher({ host, system, env: f.env, makeLoader, harnessVersion: 'fixture', readOnly: [fake], sessionDefaults: { pollInterval: 30 } });
await launcher.listen();
t.after(() => host.close(0));
if (revoked) {
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'));
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('fixture-revoked',?,'demo','launch.revoked',NULL,'{}')").run(new Date().toISOString());
db.close();
}
const bound = {};
for (const role of ['chief','pm']) {
bound[role] = await host.bindLaunch({ business: 'demo', role, run: role+'-fixture', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
const c = new Client({ path: host.path, cap: bound[role].cap }); await c.call('role.claim');
}
return { root, f, host, launcher, bound };
}
function requestHost(x) {
const operations = {
identity: m => x.bus.identity(m.cap), authorizeLaunch: m => x.bus.authorizeLaunch(m.cap, m.instance),
refuse: m => x.bus.refuse(m.cap, m.code), credentialStatus: m => x.bus.credentialStatus(m.business, m.role),
modelPreflight: m => x.bus.modelPreflight(m), mayLaunch: m => x.bus.mayLaunch(m),
landHeld: m => x.bus.landHeld(m.record), landHolders: m => x.bus.landHolders(m.business),
sessionClears: m => x.bus.sessionClears(m.business),
threadsState: m => x.bus.threadsState(m.business), lifecycleRequests: m => x.bus.lifecycleRequests(m.business),
'request.admission': m => x.bus.admission(m), 'request.launches': m => x.bus.requestLaunches(m.business),
'request.launchResult': m => x.bus.requestLaunchResult(m.record), 'request.manifest': m => x.bus.requestManifest(m.record),
'request.release': m => x.bus.requestRelease(m.cap, m.record), 'request.chief': m => x.bus.requestChief(m.business),
endLaunch: m => x.bus.endLaunch(m.record),
};
return { business: 'demo', path: x.bus.path, dataRoot: x.f.dataRoot, beforeClose: () => {},
op: m => Promise.resolve().then(() => {
assert.ok(operations[m.op], `unexpected host operation ${m.op}`); return operations[m.op](m);
}),
bindLaunch: record => { const out = x.bus.bindLaunch(record); x.caps[record.role] = out.cap; return out; } };
}
test('a later project admission cannot revive an earlier archived manifest binding',async t=>{
const {requestFixture}=await import('../../bus/tests/request-fixture.mjs');
const x=await requestFixture(t);withoutHostCredentials(x.f.env,{piKey:true});
const project=x.call('human','project.create',{name:'archive factory',repo:'stack'}).id;
const host=requestHost(x),op=host.op;let timerReads=0,archived=false;
host.op=async m=>{
const out=await op(m);
if(m.op==='request.admission'&&m.project==='timer'&&++timerReads===2){
x.call('human','project.archive',{project});archived=true;
}
return out;
};
const fake=fileURLToPath(new URL('../../harness/tests/fixtures/fake-adapter.mjs', import.meta.url));
const adapter=join(x.f.dataRoot,'archive-late.sh');writeFileSync(adapter,`exec '${process.execPath}' '${fake}'\n`);
const launcher=createLauncher({host,system:x.system,env:x.f.env,
makeLoader:await fakeHarnesses(t,{pi:adapter,'claude-code':adapter}),harnessVersion:'fixture',readOnly:[fake],sessionDefaults:{pollInterval:25}});
t.after(()=>launcher.close());await launcher.listen();
x.call('coder','role.release');
const claimsBefore=x.store.get("SELECT count(*) AS n FROM role_claims WHERE business='demo' AND role='coder' AND op='claim'").n;
let spawned=0; const original=cp.spawn;
cp.spawn=(command,args,opts)=>{ if(opts?.cwd?.startsWith(join(x.f.dataRoot,'launches'))) spawned++; return original(command,args,opts); };
syncBuiltinESMExports();
let out; try { out=await launcher.fromSession(x.caps.chief,'coder'); } finally { cp.spawn=original; syncBuiltinESMExports(); }
const launched=launcher.registry.sessions.find(s=>s.run===out.result?.run);
const projects=launched?JSON.parse(readFileSync(join(launched.runDir,'bundle/manifest.json'))).projects:[];
let claimed=null;for(let i=0;out.ok&&i<100;i++){claimed=x.store.get("SELECT holder_run FROM role_claims WHERE holder_run=? AND op='claim'",out.result.run);if(claimed)break;await new Promise(r=>setTimeout(r,25));}
console.log('STALE_MANIFEST',JSON.stringify({out,projects,timerReads,archived,claimed,current:x.bus.admission({business:'demo',project:'stack'})}));
await launcher.close();
assert.equal(archived,true); assert.equal(spawned,0); assert.equal(out.ok,false); assert.equal(claimed,null);
assert.equal(x.store.get("SELECT count(*) AS n FROM role_claims WHERE business='demo' AND role='coder' AND op='claim'").n, claimsBefore);
});
+1 -1
View File
@@ -1,7 +1,7 @@
// Keep trusted in-process launcher fixtures on the production request-host protocol.
// Every entry forwards to the actual runtime; there is no unconditional ready/allow.
export const requestHostOps = runtime => ({
'request.admission': m => runtime.admission({ business: m.business, project: m.project }),
'request.admission': m => runtime.admission(m),
'request.launches': m => runtime.requestLaunches(m.business),
'request.launchResult': m => runtime.requestLaunchResult(m.record),
'request.release': m => runtime.requestRelease(m.cap, m.record),
@@ -88,6 +88,7 @@ async function start(t, { f, adapter, access }, threadsState) {
assert.equal(trackers, undefined);
const runtime = await startBroker({ ...boot, access });
const ops = {
'request.admission': m => runtime.admission(m),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
@@ -104,6 +104,7 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
'request.admission': m => runtime.admission(m),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
+1 -1
View File
@@ -2073,7 +2073,7 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
// Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included.
for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
execFileSync('chmod',['-R','a-w',d]);
}