fix(bus,webui): pr2 review round, WebUI receipt carries routeFrom, design note, member and project tests (#1873, lane pr2)

The WebUI packs receipt passes routeFrom through on each hire line; the WebUI
README and TOOLS.md name it. The starter packs design doc gets a dated note
for the 2026-11-14 route ruling. New bus tests: a member pack hire on the
assistant route marks hired and awaiting-owner lines routeFrom assistant, and
a project pack with no chief route hires into the project on the assistant's
harness and model.
This commit was merged in pull request #1963.
This commit is contained in:
jason.woltje committed 2026-11-14 18:37:28 -06:00
1 parent bfd2cc5e79
commit b913f0f4c8
6 files changed
+38 -5

No files matched your search

+1 -1
View File
@@ -346,7 +346,7 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`. The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
Starter packs (#1873): `mosaic org pack list`, `mosaic org pack preview ID PACK [--project P]`, `mosaic org pack apply ID PACK --digest D [--project P]` and `mosaic org pack hire ID PACK [--project P]` call the bus as the organization's human (`packs`, `pack.preview`, `pack.apply`, `pack.hire`). Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's apply is the hire decision. After the pack commits, the same act hires each proposed agent through the hire-decisions path and the receipt's `hires` lists `{name, state, decision | code}` per proposal (`hired`, `exists`, `open`, `awaiting-owner`, `refused`). The owner's apply approves the flagged and credential lines; a credential line is approved but not bound, so the agent's launch refuses `credential-unbound` until its services are bound. The bus verb `pack.hire.console` also takes a member's console cap with `org.hire`: it hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. No member surface calls it yet: the WebUI packs routes and the CLI `pack` words are the owner's. An owner's pack hire also replaces and approves a hire this pack's own member hire left in the inbox; the old decision is withdrawn only after the new hire applies. Two pack hires on one organization at once: the second refuses `organization-busy`; an apply whose hire loop did not run still answers the apply's receipt with `hireError` and no hires. An agent's apply hires nothing. `pack hire` reruns the loop on a pack already applied and hires none twice; it refuses `pack-not-applied` when the pack has no packs entry, and a hire runs as the chief of staff's harness and model, else the organization assistant's (the instance whose definition is `assistant`), and a proposal refuses `pack-hire-route-missing` only when neither has both. All pack words refuse inside an agent run (exit 3). The WebUI pack card's "Hire the N proposed agents" is `POST /api/orgs/<id>/packs/hire`. Details: `packages/cli/README.md`, `packages/bus/README.md` and `packages/webui/README.md`, "Starter pack hires"; design: `docs/plans/2026-11-12_starter-packs-design.md` section 3. Starter packs (#1873): `mosaic org pack list`, `mosaic org pack preview ID PACK [--project P]`, `mosaic org pack apply ID PACK --digest D [--project P]` and `mosaic org pack hire ID PACK [--project P]` call the bus as the organization's human (`packs`, `pack.preview`, `pack.apply`, `pack.hire`). Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's apply is the hire decision. After the pack commits, the same act hires each proposed agent through the hire-decisions path and the receipt's `hires` lists `{name, state, decision | code}` per proposal (`hired`, `exists`, `open`, `awaiting-owner`, `refused`); a `hired` or `awaiting-owner` line adds `routeFrom: 'chief' | 'assistant'`. The owner's apply approves the flagged and credential lines; a credential line is approved but not bound, so the agent's launch refuses `credential-unbound` until its services are bound. The bus verb `pack.hire.console` also takes a member's console cap with `org.hire`: it hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. No member surface calls it yet: the WebUI packs routes and the CLI `pack` words are the owner's. An owner's pack hire also replaces and approves a hire this pack's own member hire left in the inbox; the old decision is withdrawn only after the new hire applies. Two pack hires on one organization at once: the second refuses `organization-busy`; an apply whose hire loop did not run still answers the apply's receipt with `hireError` and no hires. An agent's apply hires nothing. `pack hire` reruns the loop on a pack already applied and hires none twice; it refuses `pack-not-applied` when the pack has no packs entry, and a hire runs as the chief of staff's harness and model, else the organization assistant's (the instance whose definition is `assistant`), and a proposal refuses `pack-hire-route-missing` only when neither has both. All pack words refuse inside an agent run (exit 3). The WebUI pack card's "Hire the N proposed agents" is `POST /api/orgs/<id>/packs/hire`. Details: `packages/cli/README.md`, `packages/bus/README.md` and `packages/webui/README.md`, "Starter pack hires"; design: `docs/plans/2026-11-12_starter-packs-design.md` section 3.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it. The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
@@ -400,6 +400,11 @@ exactly as if a person had made them by hand. The steps:
secret. secret.
- The route is the chief of staff's resolved harness and model. With - The route is the chief of staff's resolved harness and model. With
none, that proposal is refused `pack-hire-route-missing` and stays open. none, that proposal is refused `pack-hire-route-missing` and stays open.
Note 2026-11-14 (lead ruling, lane pr2): the route is the chief's, else
the organization assistant's (the instance whose definition is
`assistant`; the one named `assistant` when there are several, else the
first in key order), and the refusal applies only when neither resolves
a harness and a model. Hire lines carry `routeFrom`.
- `pack.hire` / `pack.hire.console` (`mosaic org pack hire ID PACK - `pack.hire` / `pack.hire.console` (`mosaic org pack hire ID PACK
[--project P]`, `POST /api/orgs/<id>/packs/hire`) runs the same loop on [--project P]`, `POST /api/orgs/<id>/packs/hire`) runs the same loop on
a pack already applied, refused `pack-not-applied` otherwise. A proposal a pack already applied, refused `pack-not-applied` otherwise. A proposal
+24
View File
@@ -341,3 +341,27 @@ test('PR2 with several assistants the one named assistant gives the route, else
assert.deepEqual(b.out.hires.map(x => [x.state, x.routeFrom]), [['hired', 'assistant'], ['hired', 'assistant'], ['hired', 'assistant']]); assert.deepEqual(b.out.hires.map(x => [x.state, x.routeFrom]), [['hired', 'assistant'], ['hired', 'assistant'], ['hired', 'assistant']]);
for (const x of b.out.hires) assert.deepEqual(first.file().roles[x.name].vars, OTHER); for (const x of b.out.hires) assert.deepEqual(first.file().roles[x.name].vars, OTHER);
}); });
test('PR2 a member pack hire on the assistant route marks hired and awaiting-owner lines routeFrom assistant', async t => {
const h = await harness(t, { route: false, assistant: ASSISTANT_ROUTE });
h.install(mixkit(h.rolesDir));
// The member's apply would not run; the owner's apply would hire everything. Record the pack first
// with the assistant route absent, then give it back and let the member hire.
const doc0 = h.file(); delete doc0.roles.assistant.vars; writeFileSync(h.path, JSON.stringify(doc0, null, 2) + '\n');
await apply(h.call, { pack: 'mixkit' });
const doc = h.file(); doc.roles.assistant.vars = { ...ASSISTANT_ROUTE }; writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.routeFrom]), [['scout-1', 'hired', 'assistant'], ['coder-1', 'awaiting-owner', 'assistant'], ['reviewer-1', 'awaiting-owner', 'assistant']]);
assert.deepEqual(h.file().roles['scout-1'].vars, ASSISTANT_ROUTE);
});
test('PR2 a project pack with no chief route hires into the project on the assistant route', async t => {
const h = await harness(t, { route: false, assistant: ASSISTANT_ROUTE });
const pack = structuredClone(codingPack());
pack.id = 'projkit'; pack.scope = 'project';
h.install(pack);
const { out } = await apply(h.call, { pack: 'projkit', project: 'stack' });
assert.deepEqual(out.hires.map(x => [x.state, x.routeFrom]), [['hired', 'assistant'], ['hired', 'assistant'], ['hired', 'assistant']]);
const agents = h.file().projects.stack.agents;
for (const x of out.hires) { assert.equal(agents[x.name].hireDecision, x.decision); assert.deepEqual(h.op('decision.raise').find(r => r.decision === x.decision).hire.route, ASSISTANT_ROUTE); }
});
+3 -1
View File
@@ -81,7 +81,9 @@ empty and `hireError` names the code, and the card says the hires did not run.
`POST /api/orgs/<id>/packs/hire` with `{pack, project?}` `POST /api/orgs/<id>/packs/hire` with `{pack, project?}`
(`pack.hire.console`) runs the same loop on a pack already applied and answers (`pack.hire.console`) runs the same loop on a pack already applied and answers
`{pack, hires}`; `pack-not-applied` is 404 and `right-required` 403. Each hire `{pack, hires}`; `pack-not-applied` is 404 and `right-required` 403. Each hire
line is `{name, state, decision | code}`. `GET /api/orgs/<id>/packs` adds, to line is `{name, state, decision | code}`, and a `hired` or `awaiting-owner`
line adds `routeFrom: 'chief' | 'assistant'`, whose harness and model the hire
got (the chief of staff's, else the organization assistant's). `GET /api/orgs/<id>/packs` adds, to
each applied entry, `hired` (the agents its hires created) and `open` (each each applied entry, `hired` (the agents its hires created) and `open` (each
proposal not hired, with its reason, its state and its decision or code, and proposal not hired, with its reason, its state and its decision or code, and
`fromPack: true` on an `awaiting-owner` line this pack's own hire raised). The `fromPack: true` on an `awaiting-owner` line this pack's own hire raised). The
+4 -2
View File
@@ -138,9 +138,11 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
return json(res, 200, result); return json(res, 200, result);
} }
// A write admitted may already have committed: its receipt is returned even if the session ended // A write admitted may already have committed: its receipt is returned even if the session ended
// meanwhile, never reported as not done. Each hire line is its name, state and decision or code. // meanwhile, never reported as not done. Each hire line is its name, state and decision or code,
// and for a raised hire routeFrom, whose harness and model it got (lane pr2).
const hires = Array.isArray(result.hires) ? result.hires.filter(plain).map(h => ({ name: h.name, state: h.state, const hires = Array.isArray(result.hires) ? result.hires.filter(plain).map(h => ({ name: h.name, state: h.state,
...(typeof h.decision === 'string' ? { decision: h.decision } : {}), ...(typeof h.code === 'string' ? { code: h.code } : {}) })) : null; ...(typeof h.decision === 'string' ? { decision: h.decision } : {}), ...(typeof h.code === 'string' ? { code: h.code } : {}),
...(['chief', 'assistant'].includes(h.routeFrom) ? { routeFrom: h.routeFrom } : {}) })) : null;
if (step === 'hire') { if (step === 'hire') {
if (!hires) return refuse(503, 'outcome-unknown'); if (!hires) return refuse(503, 'outcome-unknown');
return json(res, 200, { pack: result.pack, hires }); return json(res, 200, { pack: result.pack, hires });
+1 -1
View File
@@ -385,7 +385,7 @@ test('PD the owner apply hires the proposals; the packs read lists them; the hir
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }); const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }); const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
assert.equal(applied, 200, JSON.stringify(receipt)); assert.equal(applied, 200, JSON.stringify(receipt));
assert.deepEqual(receipt.hires.map(h => [h.name, h.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]); assert.deepEqual(receipt.hires.map(h => [h.name, h.state, h.routeFrom]), [['pm-1', 'hired', 'chief'], ['coder-1', 'hired', 'chief'], ['reviewer-1', 'hired', 'chief']]);
for (const h of receipt.hires) assert.match(h.decision, /^[0-9a-f-]{36}$/); for (const h of receipt.hires) assert.match(h.decision, /^[0-9a-f-]{36}$/);
const disk = f.onDisk(); const disk = f.onDisk();
for (const h of receipt.hires) { assert.equal(disk.roles[h.name].hireDecision, h.decision); assert.ok(disk.launch.instances.includes(h.name)); } for (const h of receipt.hires) { assert.equal(disk.roles[h.name].hireDecision, h.decision); assert.ok(disk.launch.instances.includes(h.name)); }