fix(bus,webui): pd review round, supersede after apply, one pack hire loop per org, hireError, fromPack (#1873, lane pd)
Review pd-review.md (changes 6). Fixed: - P2#2: the owner's pack hire withdraws a pack-raised inbox decision only after its replacement hire applied; a replacement that refuses (route missing, prepare refusal) leaves the old decision open in the inbox. - P3#3: an apply whose hire loop throws after the apply committed still answers the apply receipt, with no hires and the code as hireError; the route passes a code through and the card says the hires did not run. - P3#5: the packs read marks an awaiting-owner line this pack's own hire raised fromPack: true; the owner's card counts it in "Hire the N". - P3#6: one pack hire loop per business; a second refuses organization-busy and raises nothing. - P2#1 docs: TOOLS.md and the bus README say no member surface calls pack.hire.console yet (the member surface is a recorded follow-up). Deferred: P3#4 project agents naming their pack (projectUnion is shared with the runtime).
This commit is contained in:
1 parent
359b3299b4
commit
3e53af3941
9 files changed
+161
-32
No files matched your search
+1
-1
@@ -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`.
|
||||
|
||||
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. A member with `org.hire` (WebUI bus verb `pack.hire.console`) hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. 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 proposal refuses `pack-hire-route-missing` when the chief of staff has no harness and model. 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`). 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 proposal refuses `pack-hire-route-missing` when the chief of staff has no harness and model. 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.
|
||||
|
||||
|
||||
+10
-4
@@ -1154,11 +1154,17 @@ decision}`. The route is the chief of staff's resolved harness and model; with
|
||||
none the proposal refuses `pack-hire-route-missing`. The entry is written with
|
||||
no credential material, so a credential line leaves its services `unbound`.
|
||||
A proposal whose instance exists is `exists`; one an open hire decision names is
|
||||
`open`, so nothing is hired twice. The owner approves every line, and withdraws
|
||||
an open decision an earlier hire of the same pack left for the owner before
|
||||
hiring the agent itself. A member's flagged or credentialed hires are raised after
|
||||
`open`, so nothing is hired twice. The owner approves every line, and replaces
|
||||
an open decision an earlier hire of the same pack left for the owner: the new
|
||||
hire is raised and applied first, and the old decision is withdrawn only once it
|
||||
has applied, so a replacement that refuses leaves the old one in the inbox. A member's flagged or credentialed hires are raised after
|
||||
the others, left open for the owner's inbox (`awaiting-owner`). A hire that
|
||||
refuses records `action.allowed {operation: 'pack.hire.refused', code}` and the
|
||||
loop goes on. The `packs` read adds `hired` and `open` to each applied entry,
|
||||
and the `instances` read adds `pack: {id, origin, project}` to each instance a
|
||||
pack hire created.
|
||||
pack hire created. An `awaiting-owner` line this pack's own hire raised carries
|
||||
`fromPack: true` (the owner's pack hire would replace it). One pack hire loop
|
||||
runs per business at a time; a second refuses `organization-busy`. An apply
|
||||
whose hire loop throws after the apply committed still answers the apply's
|
||||
receipt, with no hires and the loop's code as `hireError`. No member surface
|
||||
calls `pack.hire.console` yet; the Console's packs routes are the owner's.
|
||||
+30
-14
@@ -639,6 +639,9 @@ export class Broker {
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#hires;
|
||||
// Businesses with a pack hire loop running: a second loop on the same business refuses
|
||||
// organization-busy rather than raising a second decision for the same agent.
|
||||
#packHiring = new Set();
|
||||
#autonomyHost;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
@@ -6360,9 +6363,13 @@ export class Broker {
|
||||
};
|
||||
// Ruling 2026-11-14: a person's apply is the hire decision. Once the apply has committed, the
|
||||
// same call hires the proposals (#packHires); an agent's apply never hires.
|
||||
// The apply has committed by then, so a hire loop that throws (organization-busy, a store error)
|
||||
// still returns the apply's receipt, with no hires and the loop's code as hireError.
|
||||
if (verb === 'pack.apply' && !agent) {
|
||||
const hired = async (value) => {
|
||||
const out = await this.#packHires(s, () => this.#packHireCaller(s), { pack: args.pack, project: args.project ?? null });
|
||||
let out;
|
||||
try { out = await this.#packHires(s, () => this.#packHireCaller(s), { pack: args.pack, project: args.project ?? null }); }
|
||||
catch (e) { out = { hires: [], hireError: e instanceof BusError && typeof e.code === 'string' ? e.code : 'outcome-unknown' }; }
|
||||
return done({ ...value, ...out });
|
||||
};
|
||||
return Promise.resolve(result).then(value => value, packFail).then(hired);
|
||||
@@ -6835,7 +6842,8 @@ export class Broker {
|
||||
for (const h of a.proposedHires) {
|
||||
if (Object.hasOwn(level, h.name)) continue;
|
||||
const decision = open.get(JSON.stringify([h.name, a.project ?? null]));
|
||||
if (decision) { a.open.push({ name: h.name, reason: h.reason, state: 'awaiting-owner', decision }); continue; }
|
||||
// fromPack: this pack's own hire raised it, so the owner's pack hire replaces and approves it.
|
||||
if (decision) { a.open.push({ name: h.name, reason: h.reason, state: 'awaiting-owner', decision, ...(raisedBy.has(decision) && mine(raisedBy.get(decision)) ? { fromPack: true } : {}) }); continue; }
|
||||
const last = events.filter(e => mine(e) && e.hire === h.name).at(-1);
|
||||
if (last?.operation === 'pack.hire.refused') a.open.push({ name: h.name, reason: h.reason, state: 'refused', code: last.code });
|
||||
else a.open.push({ name: h.name, reason: h.reason, state: 'proposed' });
|
||||
@@ -6856,6 +6864,12 @@ export class Broker {
|
||||
// refuses records action.allowed pack.hire.refused with its code, and the loop goes on.
|
||||
async #packHires(s, callerOf, { pack, project = null }) {
|
||||
if (!this.#organization || !this.#hires) fail('not-configured');
|
||||
if (this.#packHiring.has(s.business)) fail('organization-busy');
|
||||
this.#packHiring.add(s.business);
|
||||
try { return await this.#packHireLoop(s, callerOf, { pack, project }); }
|
||||
finally { this.#packHiring.delete(s.business); }
|
||||
}
|
||||
async #packHireLoop(s, callerOf, { pack, project }) {
|
||||
const caller = callerOf();
|
||||
const read = () => this.#organization(s.business, 'pack.state', { pack, project }, caller.actor, {});
|
||||
const state = read();
|
||||
@@ -6874,18 +6888,13 @@ export class Broker {
|
||||
if (state.names.includes(name)) { out.push({ name, state: 'exists' }); continue; }
|
||||
const openId = open.get(JSON.stringify([name, project]));
|
||||
// The owner's pack hire approves every line, so an open decision an earlier pack hire of this
|
||||
// same pack left for the owner is withdrawn by the owner and replaced by one approved now. Its
|
||||
// pin may be stale after the hires approved since. Any other open decision is left alone.
|
||||
// same pack left for the owner is replaced by one approved now (its pin may be stale after the
|
||||
// hires approved since). The old one is withdrawn only once the new hire has applied: a
|
||||
// replacement that refuses leaves it open in the owner's inbox. Any other open decision is left alone.
|
||||
let supersedes = null;
|
||||
if (openId) {
|
||||
const by = raisedBy.get(openId);
|
||||
if (caller.owner !== true || !by || by.pack !== entry.id || (by.origin ?? null) !== (entry.origin ?? null) || (by.project ?? null) !== project) { out.push({ name, state: 'open', decision: openId }); continue; }
|
||||
this.#store.transaction(() => {
|
||||
if (this.#closed(openId)) return;
|
||||
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
openId, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid);
|
||||
this.#event(s, 'human.input', { kind: 'answer', decision: openId, op: 'withdrawn', choice: null, via: caller.via ?? 'console', subject: caller.via === 'cli' ? null : s.console, decisionSeq: seq }, openId);
|
||||
});
|
||||
supersedes = openId;
|
||||
}
|
||||
let input;
|
||||
@@ -6894,11 +6903,18 @@ export class Broker {
|
||||
input = { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name, role: this.#pinRole(state.noHire, hire), route: structuredClone(state.route),
|
||||
reason: typeof hire.reason === 'string' && hire.reason.trim() ? hire.reason : `Proposed by starter pack ${entry.id}.` };
|
||||
} catch (e) { refused(name, e); continue; }
|
||||
let r;
|
||||
try {
|
||||
const r = await this.#raiseHire(s, input, true, { caller: callerOf, pack: provenance, defer: caller.owner !== true });
|
||||
if (r?.deferred) { deferred.push(input); continue; }
|
||||
out.push({ name, state: 'hired', decision: r.id, ...(supersedes ? { supersedes } : {}) });
|
||||
} catch (e) { refused(name, e); }
|
||||
r = await this.#raiseHire(s, input, true, { caller: callerOf, pack: provenance, defer: caller.owner !== true });
|
||||
} catch (e) { refused(name, e); continue; }
|
||||
if (r?.deferred) { deferred.push(input); continue; }
|
||||
if (supersedes) this.#store.transaction(() => {
|
||||
if (this.#closed(supersedes)) return;
|
||||
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
supersedes, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid);
|
||||
this.#event(s, 'human.input', { kind: 'answer', decision: supersedes, op: 'withdrawn', choice: null, via: caller.via ?? 'console', subject: caller.via === 'cli' ? null : s.console, decisionSeq: seq }, supersedes);
|
||||
});
|
||||
out.push({ name, state: 'hired', decision: r.id, ...(supersedes ? { supersedes } : {}) });
|
||||
}
|
||||
for (const input of deferred) {
|
||||
try {
|
||||
|
||||
@@ -253,3 +253,46 @@ test('PD the new codes are in the refusal table with a rail and a next step that
|
||||
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
|
||||
}
|
||||
});
|
||||
|
||||
test('PD the owner supersede withdraws an inbox decision only after its replacement hires; a refused replacement leaves it open', async t => {
|
||||
const h = await harness(t, { route: false });
|
||||
h.install(mixkit(h.rolesDir));
|
||||
await apply(h.call, { pack: 'mixkit' });
|
||||
const setRoute = (vars) => { const doc = h.file(); if (vars) doc.roles.chief.vars = { ...vars }; else delete doc.roles.chief.vars; writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n'); };
|
||||
setRoute(ROUTE);
|
||||
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
|
||||
const waiting = out.hires.filter(x => x.state === 'awaiting-owner');
|
||||
assert.equal(waiting.length, 2);
|
||||
// The packs read marks each member-raised line as raised by a pack hire.
|
||||
const open = (await h.call('packs.console')).applied.find(a => a.id === 'mixkit').open;
|
||||
assert.deepEqual(open.map(o => [o.name, o.state, o.fromPack]), [['coder-1', 'awaiting-owner', true], ['reviewer-1', 'awaiting-owner', true]]);
|
||||
// The chief loses its route; the owner's pack hire cannot build a replacement.
|
||||
setRoute(null);
|
||||
const owner = await h.call('pack.hire.console', { pack: 'mixkit' });
|
||||
assert.deepEqual(owner.hires.map(x => [x.name, x.state, x.code ?? null]), [['scout-1', 'exists', null], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
|
||||
// Both inbox decisions are still open: nothing withdrew them.
|
||||
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op FROM decision_events WHERE decision=?', x.decision), []);
|
||||
// With the route back, the owner's pack hire replaces them, withdrawing each only once its replacement applied.
|
||||
setRoute(ROUTE);
|
||||
const done = await h.call('pack.hire.console', { pack: 'mixkit' });
|
||||
assert.deepEqual(done.hires.map(x => [x.name, x.state, x.supersedes ?? null]), [['scout-1', 'exists', null], ['coder-1', 'hired', waiting[0].decision], ['reviewer-1', 'hired', waiting[1].decision]]);
|
||||
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op, by FROM decision_events WHERE decision=?', x.decision).map(r => [r.op, r.by]), [['withdrawn', 'jason']]);
|
||||
});
|
||||
|
||||
test('PD a second pack hire on the same organization while one runs refuses organization-busy and raises nothing', async t => {
|
||||
const h = await harness(t, { route: false });
|
||||
await apply(h.call);
|
||||
const doc = h.file();
|
||||
doc.roles.chief.vars = { ...ROUTE };
|
||||
writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
|
||||
const first = h.call('pack.hire.console', { pack: 'coding' });
|
||||
const second = h.call('pack.hire.console', { pack: 'coding' });
|
||||
await assert.rejects(second, /organization-busy/);
|
||||
const hired = await first;
|
||||
assert.deepEqual(hired.hires.map(x => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
|
||||
// One decision per hire: the refused call raised nothing.
|
||||
assert.equal(h.op('decision.raise').length, 3);
|
||||
// Once the loop ends the latch is free again: the next pack hire runs and finds nothing to hire.
|
||||
const later = await h.call('pack.hire.console', { pack: 'coding' });
|
||||
assert.ok(later.hires.every(x => x.state === 'exists'), JSON.stringify(later));
|
||||
});
|
||||
@@ -76,12 +76,15 @@ peers in dev mode is a recorded follow-up.
|
||||
"Damnit. If I choose a starter pack and apply it, that is a decision!"
|
||||
`POST /api/orgs/<id>/packs/apply` (`pack.apply.console`) hires the pack's
|
||||
proposed agents in the same act, and its receipt is `{revision, added, pack,
|
||||
hires}`. `POST /api/orgs/<id>/packs/hire` with `{pack, project?}`
|
||||
hires}`; when the apply committed but its hire loop did not run, `hires` is
|
||||
empty and `hireError` names the code, and the card says the hires did not run.
|
||||
`POST /api/orgs/<id>/packs/hire` with `{pack, project?}`
|
||||
(`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
|
||||
line is `{name, state, decision | code}`. `GET /api/orgs/<id>/packs` adds, to
|
||||
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). The
|
||||
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
|
||||
packs routes stay the owner's: a member's pack hire is the bus verb's
|
||||
(`pack.hire.console` needs `org.hire`, and the flagged hires wait for the
|
||||
owner), but no member page calls it yet.
|
||||
@@ -89,7 +92,8 @@ owner), but no member page calls it yet.
|
||||
The pack card says what applying does before the click ("applying is the hire
|
||||
decision, and approves the flagged and credential lines below"), shows "N
|
||||
agents hired" and the open proposals with their reasons after it, and offers
|
||||
"Hire the N proposed agents" for proposals not waiting for the owner. Apply,
|
||||
"Hire the N proposed agents" for proposals not waiting for the owner, and for
|
||||
the owner's own `fromPack` lines, which the owner's hire replaces and approves. Apply,
|
||||
hire and fire dispatch `mosaic-org-changed`, so the Agents page and the
|
||||
organization page redraw in the same cycle instead of at the next 10-second
|
||||
poll. Each agent card a pack hire made says "from pack X". The owner's Agents
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
|
||||
if (!Array.isArray(result.packs)) return refuse(503, 'outcome-unknown');
|
||||
const applied = Array.isArray(result.applied) ? result.applied.filter(plain).map(a => ({ id: a.id, origin: a.origin ?? null, project: a.project ?? null,
|
||||
hired: Array.isArray(a.hired) ? a.hired.filter(n => typeof n === 'string') : [],
|
||||
open: Array.isArray(a.open) ? a.open.filter(plain).map(o => ({ name: o.name, reason: typeof o.reason === 'string' ? o.reason : '', state: o.state, ...(typeof o.code === 'string' ? { code: o.code } : {}), ...(typeof o.decision === 'string' ? { decision: o.decision } : {}) })) : [] })) : [];
|
||||
open: Array.isArray(a.open) ? a.open.filter(plain).map(o => ({ name: o.name, reason: typeof o.reason === 'string' ? o.reason : '', state: o.state, ...(typeof o.code === 'string' ? { code: o.code } : {}), ...(typeof o.decision === 'string' ? { decision: o.decision } : {}), ...(o.fromPack === true ? { fromPack: true } : {}) })) : [] })) : [];
|
||||
return json(res, 200, { packs: result.packs, applied, writable: Boolean(write) });
|
||||
}
|
||||
if (step === 'preview') {
|
||||
@@ -146,7 +146,9 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
|
||||
return json(res, 200, { pack: result.pack, hires });
|
||||
}
|
||||
if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack, hires: hires ?? [] } : {}) });
|
||||
// hireError: the apply committed but its hire loop did not run (organization-busy, outcome-unknown).
|
||||
const hireError = step === 'apply' && typeof result.hireError === 'string' && /^[a-z0-9-]{1,64}$/.test(result.hireError) ? { hireError: result.hireError } : {};
|
||||
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack, hires: hires ?? [], ...hireError } : {}) });
|
||||
}
|
||||
|
||||
// SP6: the workflow list and editor's read. Only workflows, the names a step role may resolve to and the
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
'definition-not-hireable': 'its role is on the no-hire list',
|
||||
'starter-pack-proposal-stale': 'the pack changed since it was applied; apply it again',
|
||||
'human-required': 'only a person can hire',
|
||||
'organization-busy': 'another pack hire on this organization was running',
|
||||
'outcome-unknown': 'the bus did not say why',
|
||||
};
|
||||
const whyText = code => HIRE_WHY[code] ?? code;
|
||||
const OPEN_STATE = { 'awaiting-owner': 'waiting for the owner in the inbox', proposed: 'proposed, not hired yet' };
|
||||
@@ -120,7 +122,9 @@
|
||||
const hired = `<p data-pack-hired="${esc(a.project ?? '')}"><strong>${n === 0 ? 'No agents hired yet' : `${n} ${n === 1 ? 'agent' : 'agents'} hired`}</strong>${where}${n ? `: ${a.hired.map(x => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`;
|
||||
const open = a.open.filter(plainObj);
|
||||
const lines = open.length ? `<ul class="pack-open" data-pack-open>${open.map(o => `<li data-pack-open-hire="${esc(o.name)}" data-pack-open-state="${esc(o.state)}"><code>${esc(o.name)}</code>: ${esc(o.reason || 'No reason given.')} <span class="muted">${esc(o.state === 'refused' ? `not hired: ${whyText(o.code)}` : OPEN_STATE[o.state] ?? o.state)}</span></li>`).join('')}</ul>` : '';
|
||||
const hireable = open.filter(o => o.state !== 'awaiting-owner').length;
|
||||
// The page is the owner's, and the owner's pack hire also approves a line this pack's own hire
|
||||
// left in the inbox (fromPack). A decision raised some other way stays the inbox's.
|
||||
const hireable = open.filter(o => o.state !== 'awaiting-owner' || o.fromPack === true).length;
|
||||
const button = hireable && s.writable ? `<button class="btn" type="button" data-pack-hire-button data-pack-hire-project="${esc(a.project ?? '')}" data-focus-key="${esc(`pack-hire:${org}:${entry.id}:${a.project ?? ''}`)}">Hire the ${hireable} proposed ${hireable === 1 ? 'agent' : 'agents'}</button>` : '';
|
||||
return `<div class="pack-applied">${hired}${lines}${button}</div>`;
|
||||
}).join('');
|
||||
@@ -174,7 +178,7 @@
|
||||
const r = await call(`${base(org)}/packs/apply`, { pack: p.pack, digest: p.value.digest, ...(p.project ? { project: p.project } : {}) });
|
||||
const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new';
|
||||
s.preview = null;
|
||||
s.status = `Applied. Added ${added}. ${hireSummary(r.hires)}`;
|
||||
s.status = `Applied. Added ${added}. ${typeof r.hireError === 'string' ? `The hires did not run: ${whyText(r.hireError)}. Use Hire on the pack card to run them.` : hireSummary(r.hires)}`;
|
||||
applied = true;
|
||||
}
|
||||
} catch (error) { s.status = error.message; }
|
||||
|
||||
@@ -29,7 +29,9 @@ const PREVIEW = {
|
||||
skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d'),
|
||||
};
|
||||
|
||||
async function fixture(t) {
|
||||
// busy: the bus's apply committed but its hire loop did not run (hireError), and the packs read lists one line a
|
||||
// member's pack hire left in the owner's inbox (fromPack), one raised some other way and one refused.
|
||||
async function fixture(t, { busy = false } = {}) {
|
||||
const verbs = [], decisions = [];
|
||||
const hired = new Map(); // name -> its pack provenance, as the bus's instances read reports it
|
||||
let applied = null, route = false, instanceReads = 0;
|
||||
@@ -42,7 +44,8 @@ async function fixture(t) {
|
||||
return { name: h.name, state: 'hired', decision };
|
||||
});
|
||||
const listed = () => applied ? [{ id: 'software-team', origin: 'shipped', project: null, hired: [...hired.keys()],
|
||||
open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`, state: 'refused', code: 'pack-hire-route-missing' })) }] : [];
|
||||
open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`,
|
||||
...(!busy || h.name === 'scout-1' ? { state: 'refused', code: 'pack-hire-route-missing' } : { state: 'awaiting-owner', decision: `inbox-${h.name}`, ...(h.name === 'coder-1' ? { fromPack: true } : {}) }) })) }] : [];
|
||||
const read = async (verb, args) => {
|
||||
verbs.push([verb, args]);
|
||||
if (verb === 'packs.console') return { packs: PACKS, applied: listed() };
|
||||
@@ -52,7 +55,7 @@ async function fixture(t) {
|
||||
};
|
||||
const write = async (verb, args) => {
|
||||
verbs.push([verb, args]);
|
||||
if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, hires: hireLoop() }; }
|
||||
if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, ...(busy ? { hires: [], hireError: 'organization-busy' } : { hires: hireLoop() }) }; }
|
||||
if (verb === 'pack.hire.console') return { pack: args.pack, hires: hireLoop() };
|
||||
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
|
||||
};
|
||||
@@ -164,3 +167,20 @@ test('PD apply hires the proposals, the pack card counts them, Hire the N propos
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 4);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
|
||||
});
|
||||
|
||||
test('PD an apply whose hire loop did not run says so; the owner card counts a pack-raised inbox line as hireable and leaves the inbox its own', { timeout: 90000 }, async t => {
|
||||
const f = await fixture(t, { busy: true });
|
||||
const { b } = f;
|
||||
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
|
||||
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
|
||||
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
|
||||
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
|
||||
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
|
||||
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. The hires did not run: another pack hire on this organization was running. Use Hire on the pack card to run them.');
|
||||
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-open]')`, 'open lines');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState])`),
|
||||
[['coder-1', 'awaiting-owner'], ['reviewer-1', 'awaiting-owner'], ['scout-1', 'refused']]);
|
||||
// coder-1 was left in the inbox by a pack hire, so the owner's pack hire approves it; reviewer-1 is the inbox's.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 2 proposed agents');
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { close } from './fixture.mjs';
|
||||
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
|
||||
const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' };
|
||||
|
||||
async function fixture(t, { send = true, workflows = true, route = false } = {}) {
|
||||
async function fixture(t, { send = true, workflows = true, route = false, tap = null } = {}) {
|
||||
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
|
||||
const root = mkdtempSync(join(tmpdir(), 'pk-'));
|
||||
const rolesDir = rolesCopy(root), system = systemFor(root);
|
||||
@@ -57,7 +57,9 @@ async function fixture(t, { send = true, workflows = true, route = false } = {})
|
||||
const upstream = `http://127.0.0.1:${board.address().port}`;
|
||||
const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); };
|
||||
const requestRead = consoleCall('read');
|
||||
const packsWrite = packsCaller(consoleCall('write'));
|
||||
// tap: a test's view of each write's bus result before the route shapes it.
|
||||
const writer = packsCaller(consoleCall('write'));
|
||||
const packsWrite = tap ? async (verb, args, ctx) => tap(verb, await writer(verb, args, ctx)) : writer;
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
@@ -71,7 +73,7 @@ async function fixture(t, { send = true, workflows = true, route = false } = {})
|
||||
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
|
||||
undo.push(() => close(web));
|
||||
const onDisk = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
return { base, file, root, calls, onDisk, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
|
||||
return { base, file, root, calls, onDisk, broker: runtime.broker, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
@@ -412,3 +414,35 @@ test('PD with no chief route the apply records the pack and each hire says pack-
|
||||
assert.deepEqual(list.applied[0].hired, []);
|
||||
assert.deepEqual(list.applied[0].open.map(o => [o.name, o.state, o.code, typeof o.reason]), [['pm-1', 'refused', 'pack-hire-route-missing', 'string'], ['coder-1', 'refused', 'pack-hire-route-missing', 'string'], ['reviewer-1', 'refused', 'pack-hire-route-missing', 'string']]);
|
||||
});
|
||||
|
||||
test('PD a member-raised awaiting-owner line reads fromPack; the owner hire route approves it; an apply whose hire loop did not run says hireError', async t => {
|
||||
const f = await fixture(t);
|
||||
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
|
||||
await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
|
||||
const doc = f.onDisk();
|
||||
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5' };
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n');
|
||||
// A member with org.hire runs the pack hire on the bus (no WebUI route serves a member yet).
|
||||
const member = f.broker.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read'] });
|
||||
const out = await f.broker.request(member, { verb: 'pack.hire.console', args: { pack: 'coding' } });
|
||||
const waiting = out.hires.filter(x => x.state === 'awaiting-owner').map(x => x.name);
|
||||
assert.ok(waiting.length > 0, JSON.stringify(out));
|
||||
const [, list] = await call(f, `${ORG}/packs`);
|
||||
const open = list.applied[0].open.filter(o => o.state === 'awaiting-owner');
|
||||
assert.deepEqual(open.map(o => [o.name, o.fromPack]), waiting.map(n => [n, true]));
|
||||
const [status, hired] = await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'coding' });
|
||||
assert.equal(status, 200, JSON.stringify(hired));
|
||||
assert.deepEqual(hired.hires.filter(x => waiting.includes(x.name)).map(x => [x.name, x.state]), waiting.map(n => [n, 'hired']));
|
||||
assert.deepEqual((await call(f, `${ORG}/packs`))[1].applied[0].open, []);
|
||||
|
||||
// The bus answers an apply whose hire loop did not run with hireError; the route passes a code through
|
||||
// and drops anything that is not one.
|
||||
for (const [given, shown] of [['organization-busy', 'organization-busy'], ['Not A Code!', undefined], [7, undefined]]) {
|
||||
const g = await fixture(t, { tap: (verb, result) => verb === 'pack.apply.console' ? { ...result, hires: [], hireError: given } : result });
|
||||
const [, p] = await call(g, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
|
||||
const [code, receipt] = await call(g, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: p.digest });
|
||||
assert.equal(code, 200, JSON.stringify(receipt));
|
||||
assert.equal(receipt.hireError, shown);
|
||||
assert.deepEqual(receipt.hires, []);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user