honker: row 194 S2d bus: the chief's instance.hire and instance.fire through a resolved decision raised with the call's arguments, the hire checks against the role files (W-N2, M60), fire steps A, B, C with kill: true, W-N1 instance-firing at the verb, the take and role.launch (D2.1-D2.3, T8-T10, T9c, T18)
This commit is contained in:
1 parent
03e5bcb3cb
commit
089b92ca34
5 files changed
+564
-8
No files matched your search
@@ -22,7 +22,7 @@ brief commit). Slices that don't need row 191 build first. S6 waits for row 191'
|
|||||||
| Slice | Scope | Tests | State |
|
| Slice | Scope | Tests | State |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
|
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
|
||||||
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13) and S2c (requests, terminate, delete) done, S2d next |
|
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) done, S2e (PRD 1.7 routing, sage-1471) next |
|
||||||
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | |
|
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | |
|
||||||
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | |
|
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | |
|
||||||
| S5 | webui: Agents section and routes | T16 | |
|
| S5 | webui: Agents section and routes | T16 | |
|
||||||
@@ -55,6 +55,9 @@ Listed here as the build changes them.
|
|||||||
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
|
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
|
||||||
its boot copy is a stub without the file's credential references. A comment there says so.
|
its boot copy is a stub without the file's credential references. A comment there says so.
|
||||||
`reload.test.mjs` covers the reload from a file boot.
|
`reload.test.mjs` covers the reload from a file boot.
|
||||||
|
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
|
||||||
|
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
|
||||||
|
checks are unchanged.
|
||||||
|
|
||||||
## S1 notes
|
## S1 notes
|
||||||
|
|
||||||
@@ -172,3 +175,48 @@ Listed here as the build changes them.
|
|||||||
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
|
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
|
||||||
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
|
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
|
||||||
not-held all died. They are not the M-list run.
|
not-held all died. They are not the M-list run.
|
||||||
|
|
||||||
|
## S2d notes (the chief's hire and fire, W-N1)
|
||||||
|
|
||||||
|
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
|
||||||
|
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
|
||||||
|
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
|
||||||
|
`arguments.name`. The raise event records it, and the human's decision view shows it under
|
||||||
|
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
|
||||||
|
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
|
||||||
|
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
|
||||||
|
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
|
||||||
|
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
|
||||||
|
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
|
||||||
|
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
|
||||||
|
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
|
||||||
|
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
|
||||||
|
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
|
||||||
|
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
|
||||||
|
definition-not-hireable before the writer. Then the writer's hire, then the consume with
|
||||||
|
`{transaction}`. Without `roleFile` a hire refuses not-configured.
|
||||||
|
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
|
||||||
|
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
|
||||||
|
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
|
||||||
|
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
|
||||||
|
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
|
||||||
|
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
|
||||||
|
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
|
||||||
|
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
|
||||||
|
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
|
||||||
|
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
|
||||||
|
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
|
||||||
|
the first one.
|
||||||
|
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
|
||||||
|
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
|
||||||
|
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
|
||||||
|
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
|
||||||
|
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
|
||||||
|
- `lifecycleView` shows `kill: true` when the request carries it.
|
||||||
|
- Deferred: the kill escalation in the launcher (S3, S6).
|
||||||
|
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
|
||||||
|
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
|
||||||
|
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
|
||||||
|
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
|
||||||
|
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
|
||||||
|
died. They are not the M-list run.
|
||||||
+145
-2
@@ -4,6 +4,8 @@ import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
|||||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||||
|
import { unhireableAction } from '../../business/src/role.mjs';
|
||||||
|
import { canonicalJson } from '../../business/src/util.mjs';
|
||||||
|
|
||||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||||
export const ACTIONS = Object.freeze([
|
export const ACTIONS = Object.freeze([
|
||||||
@@ -119,11 +121,20 @@ const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancell
|
|||||||
// limit: the launcher checks each run's start time before it signals.
|
// limit: the launcher checks each run's start time before it signals.
|
||||||
export const LIFECYCLE_REQUEST_MS = 60_000;
|
export const LIFECYCLE_REQUEST_MS = 60_000;
|
||||||
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
|
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
|
||||||
|
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire arguments, allowed then required. A raise of
|
||||||
|
// role.hire or role.fire carries them, and the call must equal them field by field (N1).
|
||||||
|
const HIRE_FIRE_ARGS = Object.freeze({
|
||||||
|
'role.hire': [['name', 'definition', 'vars'], ['name', 'definition']],
|
||||||
|
'role.fire': [['name', 'reason'], ['name', 'reason']],
|
||||||
|
});
|
||||||
|
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
|
||||||
|
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
|
||||||
// What instance.request and the host ops answer about a request.
|
// What instance.request and the host ops answer about a request.
|
||||||
const lifecycleView = (r) => ({
|
const lifecycleView = (r) => ({
|
||||||
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
|
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
|
||||||
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
|
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
|
||||||
...('runs' in r ? { runs: r.runs } : {}),
|
...('runs' in r ? { runs: r.runs } : {}),
|
||||||
|
...(r.kill === true ? { kill: true } : {}),
|
||||||
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
|
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
|
||||||
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
|
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
|
||||||
});
|
});
|
||||||
@@ -257,11 +268,12 @@ export class Broker {
|
|||||||
#limits;
|
#limits;
|
||||||
#harnessOn;
|
#harnessOn;
|
||||||
#testerOn;
|
#testerOn;
|
||||||
|
#roleFile;
|
||||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||||
#stops = new Map();
|
#stops = new Map();
|
||||||
#clock = 0;
|
#clock = 0;
|
||||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null }) {
|
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
|
||||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||||
this.#businesses = structuredClone(businesses);
|
this.#businesses = structuredClone(businesses);
|
||||||
this.#store = store;
|
this.#store = store;
|
||||||
@@ -281,6 +293,10 @@ export class Broker {
|
|||||||
// tester lifecycle call and at the take. Without it the broker's own copy decides.
|
// tester lifecycle call and at the take. Without it the broker's own copy decides.
|
||||||
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
||||||
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
|
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
|
||||||
|
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
|
||||||
|
// stands, for the hire checks. Without it a hire refuses not-configured.
|
||||||
|
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
|
||||||
|
this.#roleFile = roleFile;
|
||||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||||
this.#requirements = loadRequirements();
|
this.#requirements = loadRequirements();
|
||||||
for (const table of [
|
for (const table of [
|
||||||
@@ -413,6 +429,15 @@ export class Broker {
|
|||||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||||
if (this.#threaded(s.business, instance)) fail(thread);
|
if (this.#threaded(s.business, instance)) fail(thread);
|
||||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||||
|
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||||
|
}
|
||||||
|
// W-N1 (webby-0014): a fire of I stopped its runs and hasn't delisted it and consumed its decision.
|
||||||
|
#firing(business, instance) {
|
||||||
|
return !!this.#store.get(
|
||||||
|
`SELECT 1 FROM events f WHERE f.business=? AND f.kind='action.allowed' AND f.subject=? AND json_extract(f.body,'$.action')='fire.stopped'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM events d WHERE d.business=f.business AND d.kind='action.allowed' AND json_extract(d.body,'$.action')='fire.done'
|
||||||
|
AND json_extract(d.body,'$.cites')=json_extract(f.body,'$.cites')) LIMIT 1`,
|
||||||
|
business, instance);
|
||||||
}
|
}
|
||||||
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
|
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
|
||||||
// always goes. Its claims were revoked at the verb.
|
// always goes. Its claims were revoked at the verb.
|
||||||
@@ -422,6 +447,7 @@ export class Broker {
|
|||||||
if (r.action !== 'lifecycle.delete') {
|
if (r.action !== 'lifecycle.delete') {
|
||||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
|
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
|
||||||
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
|
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
|
||||||
|
if (this.#firing(business, r.instance)) return 'instance-firing';
|
||||||
}
|
}
|
||||||
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
|
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
|
||||||
return null;
|
return null;
|
||||||
@@ -805,7 +831,8 @@ export class Broker {
|
|||||||
options: JSON.parse(d.options),
|
options: JSON.parse(d.options),
|
||||||
blocking: !!d.blocking,
|
blocking: !!d.blocking,
|
||||||
authorization: context
|
authorization: context
|
||||||
? { action: d.action, target: context.target, approvalChoice: context.approvalChoice }
|
? { action: d.action, target: context.target, approvalChoice: context.approvalChoice,
|
||||||
|
...(context.arguments ? { arguments: context.arguments } : {}) }
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -900,6 +927,7 @@ export class Broker {
|
|||||||
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
|
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
|
||||||
)
|
)
|
||||||
fail('launch-revoked');
|
fail('launch-revoked');
|
||||||
|
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
|
||||||
const cls = this.#classification(s, action, target);
|
const cls = this.#classification(s, action, target);
|
||||||
if (cls === 'within-role' && !decision) return { class: cls };
|
if (cls === 'within-role' && !decision) return { class: cls };
|
||||||
if (!decision) fail('decision-required');
|
if (!decision) fail('decision-required');
|
||||||
@@ -1430,6 +1458,8 @@ export class Broker {
|
|||||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||||
|
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||||
|
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||||
@@ -1520,12 +1550,115 @@ export class Broker {
|
|||||||
throw this.#refused(s, e, request);
|
throw this.#refused(s, e, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire, hard waits until PRD 1.7. Each cites a
|
||||||
|
// decision the human resolved, raised with these exact arguments. The writer commits outside the
|
||||||
|
// store transaction, so the decision is checked before it and consumed only after it, as
|
||||||
|
// organization.set's grant is.
|
||||||
|
#chiefLifecycle(s, verb, a) {
|
||||||
|
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
|
||||||
|
this.#agent(s);
|
||||||
|
const [allowed, required] = HIRE_FIRE_ARGS[action];
|
||||||
|
keys(a, [...allowed, 'decision'], [...required, 'decision']);
|
||||||
|
identifier(a.decision);
|
||||||
|
const { decision, ...args } = a;
|
||||||
|
this.#hireFireArgs(action, args);
|
||||||
|
const b = this.#business(s.business);
|
||||||
|
if (b.launch?.by !== s.role) fail('role-not-chief');
|
||||||
|
if (!this.#organization) fail('not-configured');
|
||||||
|
if (!this.#store.get('SELECT 1 FROM decisions WHERE id=? AND business=?', decision, s.business) &&
|
||||||
|
this.#store.get('SELECT 1 FROM decisions WHERE id=?', decision)) fail('decision-mismatch');
|
||||||
|
const authority = { decision, target: args.name };
|
||||||
|
this.#checkAuthority(s, action, authority);
|
||||||
|
if (canonicalJson(this.#raised(s.business, decision)?.arguments ?? null) !== canonicalJson(args)) fail('decision-mismatch');
|
||||||
|
if (this.#spent(s.business, decision)) fail('decision-consumed');
|
||||||
|
// D2.4: a kept name is neither hired nor fired, whatever the decision.
|
||||||
|
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
|
||||||
|
const actor = b.roles[s.role].definition + '.' + s.role;
|
||||||
|
const context = { instance: s.role, run: s.run, decision };
|
||||||
|
const write = (op) => {
|
||||||
|
try {
|
||||||
|
return this.#organization(s.business, op, args, actor, context);
|
||||||
|
} catch (e) {
|
||||||
|
this.#organizationFail(e, verb);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (hire) {
|
||||||
|
this.#hireable(s, args.definition);
|
||||||
|
const result = write('instance.hire');
|
||||||
|
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { transaction: result.transaction }));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||||
|
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||||
|
if (b.launch?.by === args.name) fail('organization-instance-referenced');
|
||||||
|
// The last listed name refuses before step A stops anything, as the writer would at step B.
|
||||||
|
const listed = b.launch?.instances ?? [];
|
||||||
|
if (listed.length === 1 && listed[0] === args.name) fail('invalid-request');
|
||||||
|
// Step A (webby-0011 B2): the store side first, so a delisted agent never keeps a live process.
|
||||||
|
// V3's revoke, cancel and end, with kill: true for the launcher's escalation.
|
||||||
|
const stopped = this.#store.transaction(() => {
|
||||||
|
const held = this.#threaded(s.business, args.name)
|
||||||
|
? this.#threadHolders(s.business, args.name)
|
||||||
|
: [this.#holder(s.business, args.name)].filter((h) => h?.op === 'claim');
|
||||||
|
for (const h of held) this.#claimEnd(s, h, 'revoke', decision, args.reason);
|
||||||
|
const runs = held.map((h) => h.holder_run);
|
||||||
|
for (const [key, r] of this.#runs)
|
||||||
|
if (r.business === s.business && r.role === args.name && !this.#ended.has(key) && !runs.includes(r.run)) runs.push(r.run);
|
||||||
|
const open = this.#openRequests(s.business, args.name).filter((r) => r.action !== 'lifecycle.end');
|
||||||
|
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
|
||||||
|
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
|
||||||
|
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: s.role });
|
||||||
|
// The request the launcher's pass takes to signal the runs. The fire's own events cite the
|
||||||
|
// decision under `cites`, so #spent doesn't count them as its use.
|
||||||
|
const request = runs.length === 0 ? null : randomUUID();
|
||||||
|
if (request)
|
||||||
|
this.#event(s, 'action.allowed', { action: 'lifecycle.end', request, via: 'chief', by: s.role, runs, reason: args.reason, kill: true }, args.name);
|
||||||
|
this.#event(s, 'action.allowed', { action: 'fire.stopped', cites: decision, request }, args.name);
|
||||||
|
return { request, runs, cancelled, taken };
|
||||||
|
});
|
||||||
|
// Step B: the delist. An instance already delisted counts as done.
|
||||||
|
const result = write('instance.fire');
|
||||||
|
// Step C: consume the decision and record the fire done.
|
||||||
|
this.#store.transaction(() => {
|
||||||
|
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||||
|
this.#event(s, 'action.allowed', { action: 'fire.done', cites: decision, transaction: result.transaction ?? null }, args.name);
|
||||||
|
});
|
||||||
|
return { ...result, ...stopped };
|
||||||
|
}
|
||||||
|
// The shape of hire and fire arguments, at the raise and at the call.
|
||||||
|
#hireFireArgs(action, args) {
|
||||||
|
identifier(args.name);
|
||||||
|
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
|
||||||
|
if (action === 'role.fire') return string(args.reason, 500);
|
||||||
|
identifier(args.definition);
|
||||||
|
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
|
||||||
|
}
|
||||||
|
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
|
||||||
|
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
|
||||||
|
#hireable(s, definition) {
|
||||||
|
if (!this.#roleFile) fail('not-configured');
|
||||||
|
const b = this.#business(s.business);
|
||||||
|
const load = (name) => {
|
||||||
|
try {
|
||||||
|
return this.#roleFile(name);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'BusinessError') fail('definition-not-hireable');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const def = load(definition);
|
||||||
|
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
|
||||||
|
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
|
||||||
|
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
|
||||||
|
}
|
||||||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
||||||
// outcome-unknown: the write may have committed.
|
// outcome-unknown: the write may have committed.
|
||||||
#organizationFail(e, verb) {
|
#organizationFail(e, verb) {
|
||||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||||
|
// Row 194: the writer's own copy of the hire and fire checks, against the file as it stands.
|
||||||
|
if ((verb === "instance.hire" || verb === "instance.fire") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
|
||||||
// Row 65: the route checks every write of an instance's route vars runs.
|
// Row 65: the route checks every write of an instance's route vars runs.
|
||||||
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
|
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
|
||||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||||
@@ -1897,10 +2030,19 @@ export class Broker {
|
|||||||
'approvalChoice',
|
'approvalChoice',
|
||||||
'spec_path',
|
'spec_path',
|
||||||
'spec_commit',
|
'spec_commit',
|
||||||
|
'arguments',
|
||||||
],
|
],
|
||||||
['action', 'question', 'options', 'recommendation', 'blocking'],
|
['action', 'question', 'options', 'recommendation', 'blocking'],
|
||||||
);
|
);
|
||||||
this.#agent(s);
|
this.#agent(s);
|
||||||
|
// Row 194 (#1692, D2.1): a hire or fire raise carries the call's arguments, so the human
|
||||||
|
// resolves exactly what runs. The target is the instance name.
|
||||||
|
if (HIRE_FIRE_ARGS[a.action]) {
|
||||||
|
const [allowed, required] = HIRE_FIRE_ARGS[a.action];
|
||||||
|
keys(a.arguments, allowed, required);
|
||||||
|
if (a.target !== a.arguments.name) fail('invalid-request');
|
||||||
|
this.#hireFireArgs(a.action, a.arguments);
|
||||||
|
} else if ('arguments' in a) fail('invalid-request');
|
||||||
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
|
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
|
||||||
const cls = this.#classification(s, a.action, a.target);
|
const cls = this.#classification(s, a.action, a.target);
|
||||||
string(a.question);
|
string(a.question);
|
||||||
@@ -1982,6 +2124,7 @@ export class Broker {
|
|||||||
task_ref: a.task_ref ?? null,
|
task_ref: a.task_ref ?? null,
|
||||||
target: a.target ?? null,
|
target: a.target ?? null,
|
||||||
approvalChoice,
|
approvalChoice,
|
||||||
|
...(HIRE_FIRE_ARGS[a.action] ? { arguments: a.arguments } : {}),
|
||||||
holderRun:
|
holderRun:
|
||||||
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
|
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
|||||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||||
import { loadBusiness } from '../../business/src/business.mjs';
|
import { loadBusiness } from '../../business/src/business.mjs';
|
||||||
|
import { loadRole } from '../../business/src/role.mjs';
|
||||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||||
import { canonicalJson } from '../../business/src/util.mjs';
|
import { canonicalJson } from '../../business/src/util.mjs';
|
||||||
import { busBusiness } from './business.mjs';
|
import { busBusiness } from './business.mjs';
|
||||||
@@ -156,7 +157,7 @@ export async function startBroker({
|
|||||||
},
|
},
|
||||||
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
||||||
// plugins.set reloads the business's limit checks in the background.
|
// plugins.set reloads the business's limit checks in the background.
|
||||||
organization:settings?(id,verb,args,actor)=>{
|
organization:settings?(id,verb,args,actor,context)=>{
|
||||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||||
if(verb==='plugins')return plugins.read(id);
|
if(verb==='plugins')return plugins.read(id);
|
||||||
if(verb==='instances')return instances.read(id);
|
if(verb==='instances')return instances.read(id);
|
||||||
@@ -171,12 +172,17 @@ export async function startBroker({
|
|||||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||||
|
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the decision in the change context.
|
||||||
|
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||||
|
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||||
else throw new BusError('invalid-request');
|
else throw new BusError('invalid-request');
|
||||||
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
|
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
|
||||||
const live=reload(id);
|
const live=reload(id);
|
||||||
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
||||||
return verb==='instances.set'?{...result,restart:!live}:result;
|
return verb==='instances.set'?{...result,restart:!live}:result;
|
||||||
}:null,
|
}:null,
|
||||||
|
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||||
|
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||||
secretCheck: (value) => credentials.assertClean(value),
|
secretCheck: (value) => credentials.assertClean(value),
|
||||||
credentialStatus: () => credentials.status(),
|
credentialStatus: () => credentials.status(),
|
||||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import { createHash } from 'node:crypto';
|
|||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { Store, schemaDigest } from '../src/store.mjs';
|
import { Store, schemaDigest } from '../src/store.mjs';
|
||||||
import { Broker } from '../src/broker.mjs';
|
import { Broker, ACTIONS } from '../src/broker.mjs';
|
||||||
|
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
|
||||||
import { startBroker } from '../src/runtime.mjs';
|
import { startBroker } from '../src/runtime.mjs';
|
||||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||||
|
import { busBusiness } from '../src/business.mjs';
|
||||||
|
import { loadBusiness } from '../../business/src/business.mjs';
|
||||||
|
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||||
|
|
||||||
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
|
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
|
||||||
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
|
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
|
||||||
@@ -44,7 +48,7 @@ const demo = (extra = {}) => ({
|
|||||||
|
|
||||||
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
|
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
|
||||||
|
|
||||||
function setup(t, extra = {}) {
|
function setup(t, extra = {}, { roleFile = null } = {}) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
|
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
|
||||||
const store = new Store(root);
|
const store = new Store(root);
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
@@ -52,13 +56,16 @@ function setup(t, extra = {}) {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
const organization = [];
|
const organization = [];
|
||||||
const writer = { error: null };
|
const writer = { error: null, during: null };
|
||||||
const b = new Broker({
|
const b = new Broker({
|
||||||
store,
|
store,
|
||||||
businesses: demo(extra),
|
businesses: demo(extra),
|
||||||
|
roleFile,
|
||||||
organization: (...args) => {
|
organization: (...args) => {
|
||||||
organization.push(args);
|
organization.push(args);
|
||||||
|
if (writer.during) writer.during(args);
|
||||||
if (writer.error) throw writer.error;
|
if (writer.error) throw writer.error;
|
||||||
|
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
|
||||||
// The human's limits read answers more than the revision; organization.revision must not.
|
// The human's limits read answers more than the revision; organization.revision must not.
|
||||||
return args[1] === 'limits'
|
return args[1] === 'limits'
|
||||||
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
|
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
|
||||||
@@ -701,3 +708,350 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
|
|||||||
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
|
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
|
||||||
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
|
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire, hard waits under PRD 1.6. Each cites a
|
||||||
|
// decision the human resolved, raised with the call's exact arguments. The role files are stubs, so
|
||||||
|
// a definition's authority is the test's to set.
|
||||||
|
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
|
||||||
|
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
|
||||||
|
const ROLE_FILES = {
|
||||||
|
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
|
||||||
|
coder: roleDoc(['message.send', 'record.write']),
|
||||||
|
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
|
||||||
|
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
|
||||||
|
wide: roleDoc(['message.send', 'task.create']),
|
||||||
|
banned: roleDoc(['message.send']),
|
||||||
|
pm: roleDoc(['message.send']),
|
||||||
|
};
|
||||||
|
const roleFile = (files) => (name) => {
|
||||||
|
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
|
||||||
|
return files[name];
|
||||||
|
};
|
||||||
|
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
|
||||||
|
return setup(t, extra, { roleFile: roleFile(files) });
|
||||||
|
}
|
||||||
|
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||||
|
const raise = (call, action, args, who = 'chief') =>
|
||||||
|
call(who, 'decision.raise', { action, target: args.name, arguments: args, question: `${action} ${args.name}?`, options, recommendation: 'yes', blocking: false });
|
||||||
|
const approved = (call, action, args) => {
|
||||||
|
const d = raise(call, action, args);
|
||||||
|
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||||
|
return d.id;
|
||||||
|
};
|
||||||
|
const HIRE = { name: 'helper', definition: 'coder' };
|
||||||
|
// The action's use, not the raise that names the same action.
|
||||||
|
const used = (allowed, action) => allowed(action).filter((e) => e.body.operation !== 'decision.raise');
|
||||||
|
const FIRE = { name: 'coder', reason: 'the work is done' };
|
||||||
|
|
||||||
|
test('row 194 T8: the chief hires through a resolved decision raised with the hire arguments, and the writer gets the decision as context', (t) => {
|
||||||
|
const { call, organization, allowed, store } = hireSetup(t);
|
||||||
|
const d = raise(call, 'role.hire', HIRE);
|
||||||
|
// A hard wait under PRD 1.6: the human decides, not the arbiter.
|
||||||
|
assert.deepEqual({ ...store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: d.id }), /decision-not-approved/);
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', HIRE), /invalid-request/);
|
||||||
|
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
const result = call('chief', 'instance.hire', { ...HIRE, decision: d.id });
|
||||||
|
assert.equal(result.transaction, 't-1');
|
||||||
|
assert.deepEqual(organization, [['demo', 'instance.hire', HIRE, 'chief-of-staff.chief', { instance: 'chief', run: 'chief-run', decision: d.id }]]);
|
||||||
|
const [use, ...rest] = used(allowed, 'role.hire');
|
||||||
|
assert.equal(rest.length, 0);
|
||||||
|
assert.equal(use.subject, 'helper');
|
||||||
|
assert.equal(use.body.decision, d.id);
|
||||||
|
assert.equal(use.body.transaction, 't-1');
|
||||||
|
// Single use: a second call is refused and doesn't reach the writer.
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: d.id }), /decision-consumed/);
|
||||||
|
assert.equal(organization.length, 1);
|
||||||
|
// The human's decision view shows the arguments it approves.
|
||||||
|
assert.deepEqual(call('human', 'decisions', { list: 'recent' }).find((x) => x.id === d.id).authorization.arguments, HIRE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8: a hire call that differs from the raised arguments, or another target, refuses decision-mismatch', (t) => {
|
||||||
|
const { call, organization } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.hire', { ...HIRE, vars: { model: 'm' } });
|
||||||
|
for (const args of [HIRE, { ...HIRE, vars: { model: 'n' } }, { ...HIRE, vars: { model: 'm', 'limits.x': 1 } }, { ...HIRE, definition: 'wide', vars: { model: 'm' } }])
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...args, decision: id }), /decision-mismatch/, JSON.stringify(args));
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { name: 'other', definition: 'coder', vars: { model: 'm' }, decision: id }), /decision-mismatch/);
|
||||||
|
// A fire decision doesn't hire, nor a hire decision fire.
|
||||||
|
const fire = approved(call, 'role.fire', FIRE);
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { name: 'coder', definition: 'coder', decision: fire }), /decision-mismatch/);
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { name: 'helper', reason: 'x', decision: id }), /decision-mismatch/);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8: the raise checks the arguments: required for hire and fire, refused on any other action, target equal to the name', (t) => {
|
||||||
|
const { call } = hireSetup(t);
|
||||||
|
const base = { question: 'q?', options, recommendation: 'yes', blocking: false };
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.hire', target: 'helper' }), /invalid-request/);
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.hire', target: 'other', arguments: HIRE }), /invalid-request/);
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.hire', target: 'helper', arguments: { ...HIRE, extra: 1 } }), /invalid-request/);
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.hire', target: 'helper', arguments: { ...HIRE, vars: { harness: 'pi' } } }), /invalid-request/);
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.fire', target: 'coder', arguments: { name: 'coder' } }), /invalid-request/);
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.revoke', target: 'coder', arguments: { name: 'coder' } }), /invalid-request/);
|
||||||
|
for (const name of ['human', 'tester'])
|
||||||
|
assert.throws(() => call('chief', 'decision.raise', { ...base, action: 'role.hire', target: name, arguments: { name, definition: 'coder' } }), /invalid-request/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8: only the chief hires or fires; the tester, the reader, the human and every other role refuse before the writer', (t) => {
|
||||||
|
const { call, organization } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.hire', HIRE);
|
||||||
|
for (const who of ['pm', 'coder', 'assistant']) {
|
||||||
|
assert.throws(() => call(who, 'instance.hire', { ...HIRE, decision: id }), /role-not-chief/, who);
|
||||||
|
assert.throws(() => call(who, 'instance.fire', { ...FIRE, decision: id }), /role-not-chief/, who);
|
||||||
|
}
|
||||||
|
assert.throws(() => call('tester', 'instance.hire', { ...HIRE, decision: id }), /tester-refused/);
|
||||||
|
assert.throws(() => call('reader', 'instance.hire', { ...HIRE, decision: id }), /read-only/);
|
||||||
|
assert.throws(() => call('human', 'instance.fire', { ...FIRE, decision: id }), /agent-required/);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8: a decision from another business refuses decision-mismatch, not decision-not-found', (t) => {
|
||||||
|
const { call, store } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.hire', HIRE);
|
||||||
|
const COLUMNS = 'at,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit';
|
||||||
|
store.run(`INSERT INTO decisions(id,business,${COLUMNS}) SELECT 'elsewhere','other',${COLUMNS} FROM decisions WHERE id=?`, id);
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'elsewhere' }), /decision-mismatch/);
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'no-such-decision' }), /decision-not-found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
|
||||||
|
const { call, organization, writer } = hireSetup(t);
|
||||||
|
const again = { name: 'coder', definition: 'coder' };
|
||||||
|
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
|
||||||
|
writer.error = Object.assign(new Error(code), { code });
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...again, decision: approved(call, 'role.hire', again) }), new RegExp(code));
|
||||||
|
}
|
||||||
|
writer.error = null;
|
||||||
|
const id = approved(call, 'role.hire', again);
|
||||||
|
call('chief', 'instance.hire', { ...again, decision: id });
|
||||||
|
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', again]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
|
||||||
|
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
|
||||||
|
for (const action of ACTIONS) {
|
||||||
|
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
|
||||||
|
const { call, organization } = hireSetup(t, LISTED, files);
|
||||||
|
const args = { name: 'helper', definition: 'wide' };
|
||||||
|
const run = () => call('chief', 'instance.hire', { ...args, decision: approved(call, 'role.hire', args) });
|
||||||
|
if (HIRE_ALLOWED.includes(action)) {
|
||||||
|
run();
|
||||||
|
assert.equal(organization.length, 1, action);
|
||||||
|
} else {
|
||||||
|
assert.throws(run, /definition-not-hireable/, action);
|
||||||
|
assert.equal(organization.length, 0, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { call, organization } = hireSetup(t);
|
||||||
|
const hire = (definition) => {
|
||||||
|
const args = { name: 'helper', definition };
|
||||||
|
return call('chief', 'instance.hire', { ...args, decision: approved(call, 'role.hire', args) });
|
||||||
|
};
|
||||||
|
// Version 1 (M60), noHire, not on the chief's list, and no role file.
|
||||||
|
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
|
||||||
|
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
hire('coder');
|
||||||
|
assert.equal(organization.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
|
||||||
|
const { call, organization } = setup(t, LISTED);
|
||||||
|
const id = approved(call, 'role.hire', HIRE);
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: id }), /not-configured/);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and consumes', (t) => {
|
||||||
|
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.fire', FIRE);
|
||||||
|
// An open launch request of the coder, made by the human.
|
||||||
|
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||||
|
let seen = null;
|
||||||
|
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: used(allowed, 'role.fire').length }; };
|
||||||
|
const result = call('chief', 'instance.fire', { ...FIRE, decision: id });
|
||||||
|
// Step A ran before the writer; step C after it.
|
||||||
|
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
|
||||||
|
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: id, reason: 'the work is done', by: 'chief-run' });
|
||||||
|
assert.deepEqual(result.runs, ['coder-run']);
|
||||||
|
assert.deepEqual(result.cancelled, [open]);
|
||||||
|
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', FIRE, 'chief-of-staff.chief', { instance: 'chief', run: 'chief-run', decision: id }]);
|
||||||
|
const [end] = allowed('lifecycle.end');
|
||||||
|
assert.equal(end.subject, 'coder');
|
||||||
|
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
|
||||||
|
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
|
||||||
|
const [use] = used(allowed, 'role.fire');
|
||||||
|
assert.equal(use.body.decision, id);
|
||||||
|
assert.equal(use.body.reason, 'the work is done');
|
||||||
|
assert.equal(use.body.transaction, result.transaction);
|
||||||
|
assert.deepEqual(allowed('fire.done').map((e) => e.body.cites), [id]);
|
||||||
|
// The human's read of the request shows the kill.
|
||||||
|
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
|
||||||
|
// A repeat after the consume stops nothing and doesn't reach the writer.
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: id }), /decision-consumed/);
|
||||||
|
assert.equal(organization.length, 1);
|
||||||
|
assert.equal(allowed('fire.stopped').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
|
||||||
|
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
|
||||||
|
const fire = (args) => call('chief', 'instance.fire', { ...args, decision: approved(call, 'role.fire', args) });
|
||||||
|
assert.throws(() => fire({ name: 'pm', reason: 'r' }), /instance-kept/);
|
||||||
|
assert.throws(() => fire({ name: 'nobody', reason: 'r' }), /unknown-role/);
|
||||||
|
assert.throws(() => fire({ name: 'assistant', reason: 'r' }), /organization-definition-refused/);
|
||||||
|
assert.throws(() => fire({ name: 'chief', reason: 'r' }), /organization-instance-referenced/);
|
||||||
|
assert.throws(() => fire({ name: 'coder', reason: 'r' }), /invalid-request/);
|
||||||
|
assert.throws(() => fire({ name: 'coder', reason: '' }), /invalid-request/);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + used(allowed, 'role.fire').length, 0);
|
||||||
|
assert.equal(lastClaim('coder').op, 'claim');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and consumes once', (t) => {
|
||||||
|
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.fire', FIRE);
|
||||||
|
writer.error = new Error('disk');
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: id }), /outcome-unknown/);
|
||||||
|
writer.error = null;
|
||||||
|
assert.equal(allowed('fire.stopped').length, 1);
|
||||||
|
assert.equal(used(allowed, 'role.fire').length, 0);
|
||||||
|
// W-N1: while it fires, no launch of the instance starts by any path.
|
||||||
|
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||||
|
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
|
||||||
|
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
|
||||||
|
// ended, so the repeat asks for its kill again, then delists and consumes.
|
||||||
|
const result = call('chief', 'instance.fire', { ...FIRE, decision: id });
|
||||||
|
assert.deepEqual(result.runs, ['coder-run']);
|
||||||
|
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||||
|
assert.equal(used(allowed, 'role.fire').length, 1);
|
||||||
|
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: id }), /decision-consumed/);
|
||||||
|
// Once done, the instance launches again.
|
||||||
|
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T9c: a failure at C leaves the decision unspent and the instance firing, and the repeat consumes it', (t) => {
|
||||||
|
const { call, organization, allowed, store, writer } = hireSetup(t);
|
||||||
|
const id = approved(call, 'role.fire', FIRE);
|
||||||
|
// The delist commits, then the step C transaction aborts.
|
||||||
|
writer.during = () => store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: id }));
|
||||||
|
writer.during = null;
|
||||||
|
store.run('DROP TRIGGER fail_c');
|
||||||
|
assert.equal(used(allowed, 'role.fire').length, 0);
|
||||||
|
assert.equal(allowed('fire.done').length, 0);
|
||||||
|
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||||
|
call('chief', 'instance.fire', { ...FIRE, decision: id });
|
||||||
|
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||||
|
assert.equal(used(allowed, 'role.fire').length, 1);
|
||||||
|
assert.deepEqual(allowed('fire.done').map((e) => e.body.cites), [id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer, whatever the decision', (t) => {
|
||||||
|
const { call, organization, lastClaim } = hireSetup(t);
|
||||||
|
for (const [action, verb, args] of [['role.hire', 'instance.hire', { name: 'pm', definition: 'coder' }], ['role.fire', 'instance.fire', { name: 'pm', reason: 'r' }]])
|
||||||
|
assert.throws(() => call('chief', verb, { ...args, decision: approved(call, action, args) }), /instance-kept/, verb);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
assert.equal(lastClaim('pm').op, 'claim');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
|
||||||
|
const { call, organization, allowed } = hireSetup(t);
|
||||||
|
const hire = approved(call, 'role.hire', HIRE);
|
||||||
|
const fire = approved(call, 'role.fire', FIRE);
|
||||||
|
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra, decision: hire }), /invalid-request/, JSON.stringify(extra));
|
||||||
|
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra, decision: fire }), /invalid-request/, JSON.stringify(extra));
|
||||||
|
}
|
||||||
|
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' }, decision: hire }), /invalid-request/);
|
||||||
|
assert.equal(organization.length, 0);
|
||||||
|
assert.equal(allowed('fire.stopped').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt names the decision, and a refusal leaves the file alone', async (t) => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||||
|
const rolesDir = rolesCopy(root);
|
||||||
|
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||||
|
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||||
|
writeJson(join(rolesDir, 'aide.json'), {
|
||||||
|
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||||
|
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||||
|
});
|
||||||
|
const chiefFile = join(rolesDir, 'chief-of-staff.json');
|
||||||
|
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
|
||||||
|
const file = join(root, 'businesses/demo.json');
|
||||||
|
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||||
|
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||||
|
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
|
||||||
|
const busForm = (dir, roles) => {
|
||||||
|
const b = loadBusiness('demo', { dir, rolesDir: roles });
|
||||||
|
const resolved = {};
|
||||||
|
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
|
||||||
|
return busBusiness(b, resolved);
|
||||||
|
};
|
||||||
|
const bus = await startBroker({
|
||||||
|
dataRoot: join(root, 'data'),
|
||||||
|
businesses: { demo: busForm(root, rolesDir) },
|
||||||
|
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||||
|
});
|
||||||
|
t.after(async () => {
|
||||||
|
await bus.close();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const { broker } = bus;
|
||||||
|
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||||
|
broker.request(chief, { verb: 'role.claim' });
|
||||||
|
const call = (cap, verb, args) => broker.request(cap, { verb, args });
|
||||||
|
const decide = (action, args) => {
|
||||||
|
const d = call(chief, 'decision.raise', { action, target: args.name, arguments: args, question: 'q?', options, recommendation: 'yes', blocking: false });
|
||||||
|
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||||
|
return d.id;
|
||||||
|
};
|
||||||
|
const receipts = () =>
|
||||||
|
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
|
||||||
|
const document = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
|
||||||
|
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
|
||||||
|
const bytes = readFileSync(file, 'utf8');
|
||||||
|
const coder = { name: 'helper', definition: 'coder' };
|
||||||
|
assert.throws(() => call(chief, 'instance.hire', { ...coder, decision: decide('role.hire', coder) }), /definition-not-hireable/);
|
||||||
|
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||||
|
|
||||||
|
const hire = { name: 'helper', definition: 'aide' };
|
||||||
|
const hired = decide('role.hire', hire);
|
||||||
|
call(chief, 'instance.hire', { ...hire, decision: hired });
|
||||||
|
assert.deepEqual(document().roles.helper, { definition: 'aide' });
|
||||||
|
assert.ok(document().launch.instances.includes('helper'));
|
||||||
|
const [h] = receipts().slice(-1);
|
||||||
|
assert.equal(h.actor, 'chief-of-staff.chief');
|
||||||
|
assert.deepEqual(h.context, { instance: 'chief', run: 'chief-run', decision: hired });
|
||||||
|
|
||||||
|
const fire = { name: 'helper', reason: 'the work is done' };
|
||||||
|
const fired = decide('role.fire', fire);
|
||||||
|
const result = call(chief, 'instance.fire', { ...fire, decision: fired });
|
||||||
|
assert.equal(result.delisted, true);
|
||||||
|
assert.ok(!document().launch.instances.includes('helper'));
|
||||||
|
assert.deepEqual(receipts().slice(-1)[0].context, { instance: 'chief', run: 'chief-run', decision: fired });
|
||||||
|
// The reloaded business sees the delist: a repeat is spent, and the chief's launch of it is refused.
|
||||||
|
assert.throws(() => call(chief, 'instance.fire', { ...fire, decision: fired }), /decision-consumed/);
|
||||||
|
const after = readFileSync(file, 'utf8');
|
||||||
|
assert.throws(() => call(chief, 'instance.fire', { ...fire, extra: 1, decision: fired }), /invalid-request/);
|
||||||
|
assert.equal(readFileSync(file, 'utf8'), after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
|
||||||
|
const { b, call, store } = hireSetup(t);
|
||||||
|
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||||
|
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||||
|
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
|
||||||
|
for (const instance of ['coder', 'pm'])
|
||||||
|
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
|
||||||
|
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', cites: 'd-' + instance, request: null }));
|
||||||
|
for (const request of [launch, regenerate]) {
|
||||||
|
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
|
||||||
|
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -37,9 +37,14 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
|||||||
|
|
||||||
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
|
// Row 194 (#1692, D2.1): a hire or fire raise carries the call's arguments.
|
||||||
|
const named = {
|
||||||
|
'role.hire': { target: 'helper', arguments: { name: 'helper', definition: 'coder' } },
|
||||||
|
'role.fire': { target: 'coder', arguments: { name: 'coder', reason: 'done' } },
|
||||||
|
};
|
||||||
for (const action of WAITS) {
|
for (const action of WAITS) {
|
||||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
|
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
|
||||||
const d = await f.raise({ action, ...args });
|
const d = await f.raise({ action, ...named[action], ...args });
|
||||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
|
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user