Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0623755079 | ||
|
|
95c0e12209 | ||
|
|
899e30a808 | ||
|
|
0583569b74 | ||
|
|
b3341c1cb1 | ||
|
|
e9fa4ab7c9 | ||
|
|
eb399d4ab6 | ||
|
|
27cf9c193e | ||
|
|
ada54d891b | ||
|
|
51fbaa80bd | ||
|
|
a58ce5dae0 | ||
|
|
8a43468060 | ||
|
|
a22cc77acd | ||
|
|
7a0553e06d | ||
|
|
eb3abba12f | ||
|
|
c78f48946a | ||
|
|
81889b58d5 | ||
|
|
7aa18b0f6e |
@@ -5219,5 +5219,3 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-21T22:28:56Z | Huey | T1 sessions design r2, scrooge-0006, mos-1235/1236, Vikunja 463 | Before: r1 347278044 lacked the exact per-session checkout/single-writer invariant and safe runner drain ordering, and admitted other role instances. After: F1 exact workspace path, fenced checkout setup, legacy shared-workspace quiescence, clear retains old dirty/untracked/unpushed checkout and creates fresh approved checkout, T2 mutant; F2 dequeue fence and generation-bound turn-boundary idle proof before runner signal, busy drain stays clearing, T4 cases; F3 THREAD_DEFINITION only per Mos scope ruling, no role eligibility change; N1 private titles/history vs owner-visible count/activity projection; N2 Claude offline adapter/managed-store-reader dependency flagged as MVP risk. Section 8 records Mos’s Q1–Q3 readings, no questions to Jason. Verification: 27 pinned references resolve, summary within 15 lines, source/delta consistency review, whitespace and installed queue guard checked. No code, queue/PRD edits, builds/suites/live changes/provider calls/credential reads.
|
||||
|
||||
2026-10-21T22:30:20Z | Huey | T1 sessions design r3, scrooge-0007 R1, Vikunja 463 | Before: r2 e19e30df2 nested new session checkouts under the retained legacy instance workspace, allowing project-name collision or historical parent inspection to reach live siblings. After: exact path is dataRoot/session-workspaces/business/instance/session, outside the legacy root; canonicalized ancestor/descendant overlap refuses; T2 nesting mutant covers historical mount/inspection and a legacy project named sessions. All other r2 content retained as approved, revision label updated. Verification: old nested path absent, manual R1-only delta, whitespace and installed queue guard checked; no code/builds/suites/live changes/provider calls/credential reads.
|
||||
|
||||
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing (#1759), library.sync human timeout | Before: the human transport gave library.sync the 5-second default, so a sync longer than that reported outcome-unknown, and six library-* codes reached the Console as 502. After: 331597709 lands Megavolt's candidate (7 paths, tree d49e4626f, manifest digest be63cfbe recomputed and matched). human-cli.mjs waits 130 s for library.sync, past the server's 120 s hold, and 5 s for other verbs. webui bus.mjs maps library-sync-busy, library-diverged and library-accept-stale to 409, library-accept-invalid to 400, and library-credential-missing and library-fetch-failed to 503. channel-proof re-pinned. Merged-tree gate on the landing commit: library 77/0, bus 628/0, webui 305/0 (1 skip), cli 749/0 (23:44:33-23:55:11Z). eb9672c91 adds the README request-timeout sentence (Dewey N1) and a DEFERRED entry for the four library codes still at 502 (Dewey N2, binds the Console library routes row). Overlap sweep before push: 268 rebased to r2 ecd9b35eb (README only, approval carried webby-0096), 194 rebased to b7. Done rev 3072, #1759 closed.
|
||||
|
||||
@@ -22,6 +22,7 @@ RUN npm ci --ignore-scripts
|
||||
# runtime adapters.
|
||||
COPY contracts /opt/mosaic/contracts
|
||||
COPY src /opt/mosaic/src
|
||||
COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/
|
||||
COPY adapters /opt/mosaic/adapters
|
||||
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
|
||||
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
|
||||
|
||||
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
|
||||
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
|
||||
Branch `honker/row194`.
|
||||
|
||||
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
|
||||
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
|
||||
53bb59462.
|
||||
|
||||
## Summary
|
||||
|
||||
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
|
||||
Create and delete change the business file. Launch, terminate and regenerate act on runs.
|
||||
- Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
|
||||
launcher takes once and carries out under `serialize`. Launches go through the one `start()`, so
|
||||
`--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
|
||||
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
|
||||
`role.hire` and `role.fire` are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed
|
||||
form waits on a PRD 1.7 draft in this packet.
|
||||
- A hire allowlist in code: the chief can hire only a definition whose every action is on `HIRE_ALLOWED`
|
||||
(message, record, review request, assigned-task updates, and two actions that matter after row 199).
|
||||
Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
|
||||
- The per-agent forbid is two lists in the business file that only the human writes: `launch.keep`
|
||||
(instances the chief can't fire, replace or end) and `launch.noHire` (definitions the chief can't hire).
|
||||
- The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
|
||||
veto changes then count without a bus restart. Hand edits still need one, except `bus.tester`.
|
||||
- No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
|
||||
role-file `hireable` key, `"hireable": []` in `roles/chief-of-staff.json`, and three business-file keys.
|
||||
|
||||
## What exists today (checked at 53bb59462)
|
||||
|
||||
| # | Fact | Where |
|
||||
|---|---|---|
|
||||
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
|
||||
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
|
||||
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
|
||||
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
|
||||
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
|
||||
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
|
||||
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
|
||||
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
|
||||
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
|
||||
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
|
||||
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
|
||||
|
||||
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
|
||||
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
|
||||
instance's runs.
|
||||
|
||||
## D1. The verbs
|
||||
|
||||
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
|
||||
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
|
||||
|
||||
| Verb | CLI | Bus verb | Effect |
|
||||
|---|---|---|---|
|
||||
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
|
||||
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
|
||||
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I and cancels I's open requests |
|
||||
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
|
||||
| V5 delete | `business instance delete B I --revision REV [--wait SECONDS]` | `instances.set` op `delete` | a request. The launcher removes I from the business file and renames its workspace |
|
||||
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
|
||||
|
||||
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
|
||||
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
|
||||
tool, and V3 is the human verb.
|
||||
|
||||
### D1.1 Requests (V2, V4, V5)
|
||||
|
||||
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
|
||||
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
|
||||
and closed, all recorded as `action.allowed` events with reserved action names:
|
||||
|
||||
| Event | Written by | Body |
|
||||
|---|---|---|
|
||||
| `lifecycle.launch`, `lifecycle.regenerate`, `lifecycle.delete` | the broker, at the verb | `{ request, via }`, subject I, actor the human or `tester`. A `human.input` event goes with it, as `launch.revoke` does |
|
||||
| `lifecycle.taken` | host op `lifecycleTake` | `{ request }` |
|
||||
| `lifecycle.done` | host op `lifecycleDone` | `{ request, run? , refusal? }` |
|
||||
| `lifecycle.cancelled` | V3, for each open request of I | `{ request, by }` |
|
||||
|
||||
1. The launcher's new `lifecyclePass` runs on the same tick as `threadPass` and `landPass`. It lists open
|
||||
requests with host op `lifecycleRequests`.
|
||||
2. For each one, under `serialize`, it calls `lifecycleTake(request)`. That op runs in one store
|
||||
transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is
|
||||
less than 60 s old (else it closes as `request-expired`), the business isn't revoked (`launch_state`,
|
||||
`launch-revoked`), I isn't retired, row 191's switch is off (`processing-halted`) for launch and
|
||||
regenerate, and for a tester request `bus.tester` is true in the file (D4.1). Any failure closes the
|
||||
request with that code. Otherwise it writes `lifecycle.taken` and answers go.
|
||||
3. Only after go does the pass act: `check(I, { human: true })` then `start()` for a launch, the V5 steps
|
||||
for a delete.
|
||||
4. The pass then calls `lifecycleDone`. A taken request never runs again, whatever happens after the take.
|
||||
If `lifecycleDone` throws, the request stays taken with no done event. The CLI reports that as
|
||||
`outcome-unknown` with the request id, and the launcher log names the run.
|
||||
5. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
|
||||
30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
|
||||
|
||||
So the human's `launch.revoke` stops every untaken request, not only new ones (B4), and a bus restart
|
||||
can't start a request older than 60 s.
|
||||
|
||||
### V2 launch
|
||||
|
||||
1. At the verb the broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start
|
||||
on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These
|
||||
repeat at the take (D1.1 item 2).
|
||||
2. After the take, `check(I, { human: true })` and `start({ instance: I, launchedBy })`, where
|
||||
`launchedBy` is the business human or `tester`. `human: true` lets the human launch an instance that
|
||||
isn't in `launch.instances`. The listing limits the chief, not the human. `launch.by` still goes through
|
||||
the `pm: true` branch (`launcher.mjs:235`), as `--pm` does today. Every other step of `check()` and
|
||||
`start()` is unchanged.
|
||||
|
||||
### V3 terminate
|
||||
|
||||
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, actor)`). A thread
|
||||
instance has all its thread claims revoked, which is exactly `assistant.stop`.
|
||||
2. In the same transaction it writes `lifecycle.cancelled` for every open (untaken) request of I, so no
|
||||
launch the human asked for before the terminate starts after it (B5).
|
||||
3. It writes `lifecycle.end` with the runs. The launcher pass sends SIGTERM to each run's runner after
|
||||
checking its start time, as the idle stop does (`launcher.mjs:526`). It also signals any run of I in the
|
||||
launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit
|
||||
handler records the end, so the launch journal and `session.ended` are unchanged.
|
||||
4. Nothing held, nothing open and nothing in the registry answers `not-held`.
|
||||
5. Terminate is allowed while row 191's switch is on. It starts nothing.
|
||||
|
||||
A taken launch request is past cancelling. Its run reaches the registry under `serialize`, and the next
|
||||
terminate ends it. The CLI's terminate output says so when a taken request exists.
|
||||
|
||||
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. `mosaic stop` and the
|
||||
limits are the operator's tools for it (N6).
|
||||
|
||||
### V4 regenerate
|
||||
|
||||
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
|
||||
request, waits for the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as
|
||||
it does across every launch today. The session is new because every non-thread launch gets a new run dir
|
||||
(`seat/src/session.mjs:107`, newRun). If the old run hasn't exited by the request's 60 s mark, the request
|
||||
closes as `regenerate-timeout`, nothing starts, and the old run is still running (N6).
|
||||
|
||||
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
|
||||
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
|
||||
|
||||
### V5 delete
|
||||
|
||||
`instances.set` gains op `delete`. At the verb the broker and the writer refuse, in this order:
|
||||
|
||||
| Refusal | When |
|
||||
|---|---|
|
||||
| `tester-refused` | the caller is the tester (B6) |
|
||||
| `organization-definition-refused` | I is END_PROTECTED |
|
||||
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
|
||||
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
|
||||
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
|
||||
| `instance-running` | the broker sees a claim for I. The human terminates first |
|
||||
| `instance-starting` | an open or taken request for I has no done event |
|
||||
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
|
||||
|
||||
The checks pass with no write, and the broker records a `lifecycle.delete` request. After the take, the
|
||||
launcher pass, under `serialize` so no `start()` of I runs alongside it:
|
||||
|
||||
1. Re-checks the registry for I (`instance-running`) and re-runs the writer's checks against the file as it
|
||||
stands. The `--revision` the human gave must still match, else `revision-conflict`.
|
||||
2. Commits the file through the organization writer. It removes `roles.I` and the references that exist
|
||||
only for I: its `launch.instances` entry, its `launch.limits.tokens.instances` entry and its
|
||||
per-instance model entry. It adds I to a new top-level list, `retired`. The build lists every place
|
||||
`validateBusinessDocument` reads a role instance name and puts each one in one of the two groups (refuse
|
||||
or remove). A reference the table doesn't name stops the build and goes back to Webby.
|
||||
3. Renames `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700. No workspace is
|
||||
no rename.
|
||||
4. Calls `lifecycleDone` with the transaction.
|
||||
|
||||
The file goes first. Once it is committed, `check()` refuses I as unknown, so no launch can open the old
|
||||
workspace even if step 3 fails. A failed rename closes the request with `workspace-rename-failed`, the
|
||||
transaction and the path left behind. A second `delete` of a retired name skips steps 1 and 2 and retries
|
||||
step 3 only. The file and evidence are already right, so this is the whole recovery.
|
||||
|
||||
What stays:
|
||||
|
||||
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
|
||||
write-once.
|
||||
- The workspace, under `.retired`. This row deletes no file. A prune with a receipt is a later act.
|
||||
- `retired` stops a later create or hire from reusing the name. Decisions bind to the role instance and
|
||||
outlive restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the
|
||||
old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding
|
||||
decisions, which would touch a second store.
|
||||
|
||||
### V6 veto
|
||||
|
||||
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
|
||||
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
|
||||
non-END_PROTECTED instance. `nohire` needs a name that matches the id pattern. A no-op answers
|
||||
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS`.
|
||||
|
||||
## D2. Chief hire and fire
|
||||
|
||||
### D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
|
||||
|
||||
This row adds `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`). Under PRD REQ-WAIT-3 a new
|
||||
action is a hard wait until a PRD version says it isn't (`vocabulary.mjs:52-57`), so both ship as hard
|
||||
waits. Jason resolves each one. There is no PRD change and no grant in a role file: `role.mjs:42` refuses
|
||||
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
|
||||
|
||||
The flow in this row:
|
||||
|
||||
1. The chief raises a `role.hire` or `role.fire` decision whose body carries the arguments (business,
|
||||
name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
|
||||
2. Jason resolves it in the Console or the CLI.
|
||||
3. The chief calls `instance.hire` or `instance.fire` citing the decision. The broker checks it with
|
||||
`#consumeAuthority(s, 'role.hire', { decision, target })`, as the `role.revoke` decision branch does,
|
||||
and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is
|
||||
`name`, `definition` and `vars` (vars compared with `canonicalJson`, absent equal to absent only). For
|
||||
fire it is `name` and `reason`. A key in the call that the body lacks, or a body key the call lacks,
|
||||
refuses `decision-mismatch`. Every check below runs again at this call, so a veto set after the resolve
|
||||
still refuses.
|
||||
|
||||
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
|
||||
moves both actions to `ROUTED`. My draft is `agents/honker/work/queue-194/PRD-1.7-DRAFT.md`. It quotes
|
||||
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a `prd.approve` decision in the Console,
|
||||
and Sage opens that decision. When it lands, a later commit adds the two actions to both `ROUTED` lists
|
||||
(`vocabulary.mjs`, `broker.mjs:43-69`) and to `roles/chief-of-staff.json` withinRole. The broker then
|
||||
takes the `reason` form instead of a decision. The verbs and every check below stay the same. This row
|
||||
builds and tests the hard-wait form only. It writes the `hireable` key and its allowlist now (D2.3), because
|
||||
both forms use them.
|
||||
|
||||
### D2.2 What the chief may do
|
||||
|
||||
| Act | Bus verb | Rule |
|
||||
|---|---|---|
|
||||
| launch | `launch` (host) | as today: listed instances only |
|
||||
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept (D2.4) |
|
||||
| hire | `instance.hire { name, definition, vars?, decision }` | adds and lists an instance. Refusals below |
|
||||
| fire | `instance.fire { name, reason, decision }` | ends I's runs, cancels I's open requests, and removes I from `launch.instances`. Refusals below |
|
||||
|
||||
Only the business's chief, the instance named by `launch.by`, may call either verb. Any other caller
|
||||
refuses `role-not-chief`, even with a resolved decision, as `#classification` already limits
|
||||
`role.launch`. The broker reads `hireable` from that chief's own role file. Both act only in the chief's
|
||||
own business. Neither verb takes a business argument. The broker uses the
|
||||
cap's business, and a decision raised in another business refuses `decision-mismatch`. Row 191's switch
|
||||
refuses both while halted (D6). Every hire and fire writes `changes.jsonl` through the organization writer
|
||||
with actor the chief's instance and run, and the decision id.
|
||||
|
||||
Hire refuses, in order:
|
||||
|
||||
1. `definition-not-hireable` when the definition holds an action outside `HIRE_ALLOWED` (D2.3), whatever
|
||||
any list says.
|
||||
2. `definition-not-hireable` when the definition isn't in the chief role file's `hireable` list, or is in
|
||||
the business's `launch.noHire`.
|
||||
3. `organization-definition-refused` for END_PROTECTED, `organization-credentials-out-of-scope` for a
|
||||
credentialed definition (until the credentials row, row 199), `invalid-request` for version 1.
|
||||
4. `organization-instance-retired` when the name is retired.
|
||||
5. When the name exists: a fired instance (in `roles`, not listed) is listed again only if `definition`
|
||||
equals its current one and `vars` is absent. Any other definition or vars refuses
|
||||
`organization-instance-exists`, and so does a listed name. A re-hire never moves an instance's
|
||||
decisions to a different role (B3).
|
||||
|
||||
Hire's `vars` may hold only the keys the chief can already set on an instance through `organization.set`
|
||||
(the model key, `broker.mjs:1266-1269`) and narrowing `limits.*` keys. Anything else, such as `harness`
|
||||
or a route, refuses `invalid-request`. The human sets those (N3).
|
||||
|
||||
Fire refuses `instance-kept` for a kept instance, `organization-definition-refused` for END_PROTECTED,
|
||||
`organization-instance-referenced` for `launch.by`, and a BusinessError for the last listed instance. A
|
||||
fired instance stays in `roles`. The chief never deletes. Delete stays a human act because it retires the
|
||||
name.
|
||||
|
||||
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
|
||||
gets the shared model token at start: `ENV_ALLOW` (`packages/seat/src/session.mjs:83-94`) passes
|
||||
`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` and the other provider variables into the sandbox. A revoke
|
||||
can't take that token back, so a fired agent whose process lives on still holds it.
|
||||
|
||||
The delist is a business file commit through the organization writer, and the revoke and events are store
|
||||
rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
|
||||
|
||||
A. One store transaction: steps 1 to 3 below, plus `fire.stopped` with the decision id. The decision isn't
|
||||
consumed yet.
|
||||
B. The delist through the organization writer (`launch.instances` loses I, logged in `changes.jsonl`).
|
||||
C. One store write consuming the decision and recording `fire.done`.
|
||||
|
||||
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
|
||||
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
|
||||
(unlike V3's `not-held`). B finds I already delisted and counts as done. C consumes. So a fire is safe to
|
||||
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
|
||||
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
|
||||
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
|
||||
covers the runs.
|
||||
|
||||
Steps 1 to 3 are V3's, with one change:
|
||||
|
||||
1. It revokes every claim the instance holds.
|
||||
2. It writes `lifecycle.cancelled` for each of the instance's open requests.
|
||||
3. It writes `lifecycle.end` with the runs and `kill: true`. The launcher pass stops each runner, spawned
|
||||
but unbound registry runs included, with escalation (sage-1380 item 1):
|
||||
- SIGTERM after the identity check.
|
||||
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
|
||||
`lifecycle.end.killed` with the run and pid.
|
||||
- A runner still alive after SIGKILL and the observe wait gets `lifecycle.end.alive`. The Console's
|
||||
Agents section shows it to the human.
|
||||
A runner whose identity no longer matches is never signalled. Every check, the recheck after the
|
||||
grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never
|
||||
counts as the fired run (webby-0011 N2).
|
||||
|
||||
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (`launch` takes listed
|
||||
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
|
||||
fire answer names it, and the human's terminate ends its run.
|
||||
|
||||
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
|
||||
design r2 (`agents/rocko/work/queue-191/DESIGN-r2.md`, "Stop policy and completion") sends SIGTERM, waits
|
||||
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
|
||||
ticks plus boot id, which replaces the start-time check at `launcher.mjs:526`. Fire calls that routine for
|
||||
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
|
||||
|
||||
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
|
||||
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt
|
||||
(row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or
|
||||
restarting the deadline, and counts the run in its remaining or quiescent totals.
|
||||
- The record says which stop owned it: reason `fire` with the decision, or row 191's `kill-switch` with
|
||||
its generation.
|
||||
|
||||
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired
|
||||
agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its
|
||||
candidate (Q177), as D6 already says.
|
||||
|
||||
### D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
|
||||
|
||||
r2 and r3 had a deny list, `HIRE_FLOOR`. Webby's B1 showed it missed routed actions (`git.merge.protected`,
|
||||
which `roles/merge-gate.json` holds with no credentials, `decision.resolve.technical`, `policy.change`,
|
||||
`prd.approve`), and that any action added later would be hireable by default. That isn't fail-closed. r4
|
||||
replaces it with an allowlist.
|
||||
|
||||
A definition is hireable only if every action in its withinRole and crossRole is on `HIRE_ALLOWED`:
|
||||
|
||||
| Allowed action | Held today by |
|
||||
|---|---|
|
||||
| `message.send`, `record.write` | every v2 role file |
|
||||
| `review.request`, `task.update.assigned` | coder, cto, pm, reviewer |
|
||||
| `task.scope.change`, `git.push.working` | coder, cto, pm (credentialed, so they matter only after row 199) |
|
||||
|
||||
Everything else in `ACTIONS` fails, including the actions of the old floor (`role.launch`, `role.end`,
|
||||
`role.revoke`, `organization.change`, `instance.name`, `review.verdict`, `role.hire`, `role.fire`) and those
|
||||
B1 named. An action added to `ACTIONS` later fails until a reviewed commit adds it here.
|
||||
`git.push.remote` (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
|
||||
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
|
||||
|
||||
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
|
||||
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
|
||||
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only `message.send` and
|
||||
`record.write`, so hiring one gives no authority beyond its name. `hireable` still has to list it.
|
||||
|
||||
The list is a constant in `business/src/vocabulary.mjs`, `HIRE_ALLOWED`. It is checked twice:
|
||||
|
||||
- At load. `role.mjs` validates the chief role file's `hireable` list: distinct definition names, each
|
||||
present in `rolesDir`, version 2, and every action on `HIRE_ALLOWED`. A list naming one that isn't refuses
|
||||
the role file (`hireable names <def>, which holds <action>`). The check reads only the named file's authority lists,
|
||||
never its `hireable`, so a list can't recurse. `hireable` is an optional key on any role file. This is a
|
||||
role schema change, named for review.
|
||||
- At hire, in the broker, against the definition as loaded then. A role file edited after load is caught
|
||||
here.
|
||||
|
||||
`hireable` in `roles/chief-of-staff.json` stays empty in this row (`[]`, Q4, sage-1374). Naming the first
|
||||
role kind is a separate policy commit in `roles/`, made after Jason resolves the `prd.approve` decision, as
|
||||
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
|
||||
until that commit. The row's tests use scratch role files whose `hireable` names a kind.
|
||||
|
||||
### D2.4 The per-agent forbid and the veto (sage-1365 item 3)
|
||||
|
||||
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids
|
||||
per agent". The forbid is two lists in the business file that only the human writes:
|
||||
|
||||
- `launch.keep`: instance names. The chief can't fire, replace or end a kept instance. The broker refuses
|
||||
`instance-kept` for every chief act aimed at it except launch:
|
||||
- `instance.fire`, even with a resolved `role.fire` decision. Mos's limit is that Jason's veto applies
|
||||
to every hire and fire, so the human unkeeps before a fire.
|
||||
- a hire of that name.
|
||||
- `role.revoke` in the reason branch (`broker.mjs:1607-1613`). End then launch is a replace (E6).
|
||||
- an `organization.set` write of that instance's model key (B7). A model change is a replace at the next
|
||||
launch.
|
||||
|
||||
The `role.revoke` decision branch (`broker.mjs:1614-1615`) still ends a kept run. It ends a run, it
|
||||
doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3
|
||||
and the limits also still end a kept run.
|
||||
- `launch.noHire`: definition names the chief can't hire into this business (D2.2 item 2).
|
||||
|
||||
Why the chief can't change the forbid:
|
||||
|
||||
- Only `veto.set` writes the lists. It is human-only (`#human(s)` then the D4 hook) and refuses the tester.
|
||||
- `organization.set`, the chief's one write verb, reaches only `ORGANIZATION_KEYS` (`broker.mjs:145`),
|
||||
which exclude `launch` and `retired`.
|
||||
- `instances.set` is human or tester only (`broker.mjs:1248-1298`).
|
||||
- Hire and fire carry no field that writes either list.
|
||||
|
||||
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
|
||||
|
||||
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
|
||||
runs, and the assistant's own stop is `assistant.stop`. Default, per "unless user forbids": nothing is
|
||||
kept and nothing is on `noHire`.
|
||||
|
||||
### D2.5 Where it lives and how the broker enforces it
|
||||
|
||||
The forbid lives in the business file, under `launch`, beside the membership it limits:
|
||||
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. `hireable`
|
||||
lives in the chief's role file, because it bounds the role, not one business.
|
||||
|
||||
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
|
||||
non-protected instances. `noHire` entries need only the id pattern (N2). A stale deny entry is harmless,
|
||||
and a reviewed commit that removes a role file must not stop every business that lists it. `retired`
|
||||
names none of `roles`. This is the business-file schema change for review. Old files without the keys load
|
||||
unchanged. An old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this
|
||||
row needs the keys removed first. The activation plan has to say so (R4).
|
||||
|
||||
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
|
||||
`role.revoke` reason branch, in `organization.set`'s model write and in the two chief verbs, before any
|
||||
write.
|
||||
|
||||
## D3. Live changes
|
||||
|
||||
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
|
||||
add and redefine.
|
||||
|
||||
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
|
||||
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
|
||||
193's ground).
|
||||
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it in one place, after every verb
|
||||
that commits a write, whatever the verb (N1). Today that is `limits.set`, `instances.set`, `routes.set`
|
||||
and `plugins.set`, and after this row `veto.set`, `instance.hire`, `instance.fire` and the launcher's
|
||||
delete through the same writer. It loads from the file through the loader `bootConfig` uses
|
||||
(`cli/src/config.mjs:88-105`), so the broker holds exactly what a restart would give it. Then
|
||||
`limits.refresh(id)` runs, as `plugins.set` does now.
|
||||
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
|
||||
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
|
||||
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
|
||||
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
|
||||
reload.
|
||||
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
|
||||
authority under its run. This is new: today the change waits for the restart. Existing tests that pin
|
||||
`restart: true` change with it (N4): `business/tests/instance-settings.test.mjs`,
|
||||
`bus/tests/instances-verb.test.mjs`, `cli/tests/instances.test.mjs:76-94` (the `RESTART_NEEDED` text and
|
||||
the restart line) and `webui/tests/instances.test.mjs:25, 71, 74, 140-145`. The build names each
|
||||
changed assertion in its manifest.
|
||||
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
|
||||
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
|
||||
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
|
||||
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
|
||||
6. Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
|
||||
`bus.tester` (D4.1), read from the file at each tester call.
|
||||
|
||||
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
|
||||
|
||||
## D4. RBAC until row 193
|
||||
|
||||
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
|
||||
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
|
||||
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
|
||||
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
|
||||
use it. Row 193 replaces the hook body and nothing else.
|
||||
|
||||
### D4.1 The tester (Sage, sage-1355)
|
||||
|
||||
What the tester may do after this row:
|
||||
|
||||
| Verb | Tester |
|
||||
|---|---|
|
||||
| `instance.launch`, `instance.end` | yes, new |
|
||||
| `instances.set` ops `add`, `change` | yes, as today (row 175, `broker.mjs:103`, `serve.mjs:537`) |
|
||||
| `instances.set` op `delete` | no, `tester-refused` in the broker (B6) |
|
||||
| `instance.regenerate`, `veto.set` | no, `tester-refused` |
|
||||
|
||||
The tester's cap is bound to one business, and only when that business sets `bus.tester` to true
|
||||
(`broker.mjs:542-546`). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
|
||||
lifecycle call, before any effect, and again at the take (D1.1):
|
||||
|
||||
1. The request's business is the cap's business. Any other refuses `tester-refused`.
|
||||
2. `bus.tester` is exactly true in the business file as it stands, read through `settings.current(id)` as
|
||||
`harnessOn` does (`runtime.mjs:136-142`), not from the broker's copy (B8). An unreadable file refuses
|
||||
`tester-off`. So a hand edit turning testing off counts at the next tester call, without a restart.
|
||||
|
||||
The request and done events record actor `tester`. The tester's Q173 tests of the Agents section then cover
|
||||
launch and terminate on a scratch business, and the rest through fixtures.
|
||||
|
||||
## D5. One stack instance
|
||||
|
||||
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
|
||||
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
|
||||
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
|
||||
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
|
||||
the other byte-identical (T11).
|
||||
|
||||
## D6. The kill switch
|
||||
|
||||
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
|
||||
The new pass adds no second start path.
|
||||
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
|
||||
is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes.
|
||||
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
|
||||
The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt
|
||||
still refuses while halted, and the chief calls it again after the resume.
|
||||
- Terminate, create, delete and veto start nothing and stay allowed.
|
||||
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
|
||||
checks are written against its reader and tested with its fixture.
|
||||
|
||||
## D7. The Console
|
||||
|
||||
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
|
||||
each instance with definition, listed, kept, retired and running runs, and flags a run with a
|
||||
`lifecycle.end.alive` event as "still running after fire" (D2.2). It has these actions:
|
||||
|
||||
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
|
||||
and the workspace under `.retired`).
|
||||
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
|
||||
- Keep and Unkeep per instance, and a No-hire toggle per definition.
|
||||
|
||||
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
|
||||
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
|
||||
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
|
||||
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
|
||||
request, then poll `GET /api/agent/request?id=` until it closes.
|
||||
|
||||
`agents.js` joins the static file list (`serve.mjs:28`) and `index.html` (N5).
|
||||
|
||||
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
|
||||
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
|
||||
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
|
||||
route gets a `--send` exception.
|
||||
|
||||
## D8. Tests
|
||||
|
||||
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
|
||||
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
|
||||
`~/.config/mosaic-dev`, a live unit or a slot file.
|
||||
|
||||
| Test | Shows | File |
|
||||
|---|---|---|
|
||||
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
|
||||
| T2 | V2: a human launch of an unlisted instance runs. `launch.by` goes through the `pm` branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
|
||||
| T2a | Take: a `launch.revoke` between the request and the pass closes it `launch-revoked` with no process. A `lifecycleDone` that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice | same |
|
||||
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
|
||||
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
|
||||
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request `regenerate-timeout` with nothing started. The assistant refuses `regenerate-thread` | same |
|
||||
| T6 | V5: each refusal in the V5 table, in order, including `instance-starting` with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds `retired`, and a create or hire of the same name refuses. A failed rename closes `workspace-rename-failed`, `check()` refuses the name, and a second delete renames only | business `instance-settings.test.mjs` and cli `lifecycle.test.mjs` |
|
||||
| T7 | V6: each op, the no-op refusal, an unknown `noHire` name accepted, and a `changes.jsonl` entry with actor `human` | business |
|
||||
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses `role-not-chief`. Outside `hireable`, in `noHire`, credentialed, END_PROTECTED, retired, and extra vars each refuse | bus `chief-of-staff.test.mjs` |
|
||||
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses `organization-instance-exists` | same |
|
||||
| T8b | Hire allowlist (D2.3): one case per action in `ACTIONS` that is neither on `HIRE_ALLOWED` nor a hard wait (`role.mjs` already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in `hireable`, is rewritten to hold that action before the hire. The hire refuses `definition-not-hireable`. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes `HIRE_ALLOWED` to the six actions | same |
|
||||
| T8c | Allowlist at load: a role file whose `hireable` names a definition holding each refused action refuses at `loadRole`, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses | business `role.test.mjs` |
|
||||
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in `roles`. Kept (even with a resolved decision), END_PROTECTED, `launch.by` and last-listed refuse | bus `chief-of-staff.test.mjs` |
|
||||
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has `lifecycle.end.killed` with its run and pid. A stand-in whose kill is stubbed to fail gets `lifecycle.end.alive`. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL | cli `launcher.test.mjs` |
|
||||
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason `fire` and the decision, and counts the run in its totals. A fire while halted refuses (D6) | cli `launcher.test.mjs`, on row 191's fixture |
|
||||
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses `decision-consumed`. With C stubbed to fail, the second fire finds I delisted and only consumes | bus `chief-of-staff.test.mjs` |
|
||||
| T10 | Keep: chief `role.revoke` with a reason, fire, hire of that name and an `organization.set` model write aimed at a kept instance each refuse `instance-kept`. A `role.revoke` citing a human-resolved decision ends the kept run (Q2). The human's V3 works | same |
|
||||
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli `lifecycle.test.mjs`, bus |
|
||||
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including `tester-refused` for `instances.set` op `delete` (B6). The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
|
||||
| T12a | Tester launch and terminate work on the cap's business with `bus.tester` true, record actor `tester`, and refuse `tester-refused` for a second business on the same bus. A file edit setting `bus.tester` to false, with no restart, refuses the next call and the take `tester-off`. An unreadable file refuses `tester-off` | bus `instances-verb.test.mjs` |
|
||||
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
|
||||
| T14 | Live reload: after each committed verb, `plugins.set` and `routes.set` included, the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
|
||||
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted` at the verb and at the take, hire and fire refuse, terminate works | bus and cli |
|
||||
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling, and `agents.js` served from the static list | webui `agents.test.mjs` (new) |
|
||||
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
|
||||
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, `veto.set`, `organization.set` with each list key, `instances.set`, and hire and fire with extra fields all refuse, and the business file bytes are unchanged | bus `chief-of-staff.test.mjs` |
|
||||
|
||||
### Mutants
|
||||
|
||||
Each one must fail at least one test. They run against the candidate, and the file is restored and its
|
||||
sha256 checked after each one.
|
||||
|
||||
| # | Change | Killer |
|
||||
|---|---|---|
|
||||
| M1 | `instance.launch` skips `#human` | T12 |
|
||||
| M2 | the pass calls `check()` without `human: true` | T2 |
|
||||
| M3 | the take accepts a request older than 60 s | T2 |
|
||||
| M4 | the take skips the `launch_state` check | T2a |
|
||||
| M5 | terminate signals without the start-time check | T4 |
|
||||
| M6 | regenerate starts before the old exit | T5 |
|
||||
| M7 | delete skips the running check | T6 |
|
||||
| M8 | delete skips `retired` | T6 |
|
||||
| M9 | delete clears `keep` instead of refusing | T6 |
|
||||
| M10 | delete removes the workspace instead of renaming it | T6 |
|
||||
| M11 | the `keep` check is removed from the `role.revoke` reason branch | T10 |
|
||||
| M12 | `noHire` is not read by hire | T8 |
|
||||
| M13 | fire deletes the instance from `roles` | T9 |
|
||||
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
|
||||
| M15 | the tool enum is kept | T8 |
|
||||
| M16 | the halted check is dropped from `instance.launch` | T15 |
|
||||
| M17 | the Console route skips the Origin check | T16 |
|
||||
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
|
||||
| M19 | the tester's business scope check is dropped | T12a |
|
||||
| M20 | the tester's `bus.tester` file read is dropped | T12a |
|
||||
| M21 | hire skips the role file's `hireable` list | T8 |
|
||||
| M22 | the take doesn't write `lifecycle.taken`, so a request can be taken twice | T2a |
|
||||
| M23 | terminate doesn't cancel open requests | T4 |
|
||||
| M24 | delete skips the `instance-starting` check | T6 |
|
||||
| M25 | delete renames before it commits the file | T6 |
|
||||
| M26 | the tester's `instances.set` op `delete` refusal is dropped | T12 |
|
||||
| M27 | the `keep` check is dropped from `organization.set`'s model write | T10 |
|
||||
| M28 | the `keep` check is added to the `role.revoke` decision branch | T10 |
|
||||
| M29 | a re-hire accepts a different definition | T8a |
|
||||
| M30 to M36 | the broker's hire check accepts one action outside `HIRE_ALLOWED`, one mutant per action: `role.launch`, `role.end`, `organization.change`, `instance.name`, `review.verdict`, `git.merge.protected`, `decision.resolve.technical` | T8b |
|
||||
| M37 to M43 | the same seven, accepted by the load check in `role.mjs` only | T8c |
|
||||
| M44 | fire skips `instance-kept` when a decision is cited | T9 |
|
||||
| M45 | `instance.hire` skips the `launch.by` caller check | T8 |
|
||||
| M46 | `veto.set` skips `#human` | T18 |
|
||||
| M47 | the broker's hire check needs some action on `HIRE_ALLOWED` instead of every action | T8b |
|
||||
| M48 | the same in `role.mjs` only | T8c |
|
||||
| M49 | fire revokes the claims but writes no `lifecycle.end`, so no runner is signalled | T9a |
|
||||
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
|
||||
| M51 | fire's stop drops the SIGKILL escalation | T9a |
|
||||
| M52 | SIGKILL is sent without the second identity check | T9a |
|
||||
| M53 | a killed runner gets no `lifecycle.end.killed` | T9a |
|
||||
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
|
||||
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
|
||||
| M56 | fire delists before the store step | T9c |
|
||||
| M57 | fire consumes the decision in step A | T9c |
|
||||
| M58 | a second fire with nothing to revoke refuses `not-held` | T9c |
|
||||
|
||||
## Scope and boundaries
|
||||
|
||||
- In the row: the verbs and passes above in `business` (vocabulary, role validation, instance-settings,
|
||||
business validation), `bus` (broker verbs, reload, runtime callback), `cli` (instances command, launcher
|
||||
pass, host ops), `harness` (the launch tool's schema, and the hire and fire tools), `webui` (routes and
|
||||
the Agents section), the tests, the package READMEs and the PRD draft in this packet. `docs/TOOLS.md`
|
||||
text goes to Sage.
|
||||
- Named for review:
|
||||
- Vocabulary: `role.hire` and `role.fire` join `ACTIONS` as hard waits, under REQ-WAIT-3. The commit
|
||||
message names them as hard waits, as REQ-WAIT-3 requires.
|
||||
- Role schema: the optional `hireable` key, validated in `role.mjs` (D2.3).
|
||||
- Role file: `roles/chief-of-staff.json` gains `"hireable": []`. No authority change.
|
||||
- Business file schema: `launch.keep`, `launch.noHire`, `retired` (D2.5).
|
||||
- Not in the row: the routed form (after PRD 1.7), filling `hireable`, credentialed hires and deletes (row
|
||||
199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any
|
||||
live activation or Console start.
|
||||
|
||||
## Risks
|
||||
|
||||
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and
|
||||
reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed
|
||||
roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this
|
||||
row's to close.
|
||||
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
|
||||
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
|
||||
check of its own.
|
||||
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
|
||||
after the CLI gave up. The CLI says so on timeout.
|
||||
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, and
|
||||
`hireable` from the role file, or the old loaders refuse them. The activation plan states this.
|
||||
- R5. `tools.mjs`, `serve.mjs`, `broker.mjs`, `vocabulary.mjs` and `role.mjs` may carry sha256 pins in
|
||||
other packages' tests. The build greps the tests for every changed path plus a 64-hex value before
|
||||
in-review, without a `head`.
|
||||
- R6. With `hireable` empty, the chief can hire nothing until the policy commit in `roles/` that follows
|
||||
the `prd.approve` resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
|
||||
- R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
|
||||
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
|
||||
human acts on `lifecycle.end.alive`. Only a per-agent model token (row 199 Q-M4, a later proxy row)
|
||||
would let a fire revoke the token itself.
|
||||
|
||||
## Questions and rulings
|
||||
|
||||
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): `role.hire` and `role.fire` ship as hard
|
||||
waits Jason resolves. The routed form needs PRD 1.7, drafted in `PRD-1.7-DRAFT.md`. D2.1.
|
||||
- Q2, ruled by Webby (webby-0003): keep blocks the chief's `role.revoke` reason branch, not its decision
|
||||
branch. D2.4.
|
||||
- Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
|
||||
business, behind `bus.tester`. D4.1.
|
||||
- Q4, ruled by Sage (sage-1374): `hireable` stays empty in this row. Naming the first role kind is a
|
||||
separate policy commit in `roles/` after Jason resolves the `prd.approve` decision. Tests use scratch role
|
||||
files that name a kind. D2.3.
|
||||
|
||||
## r2 changes against webby-0003
|
||||
|
||||
| Finding | Where |
|
||||
|---|---|
|
||||
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
|
||||
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
|
||||
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
|
||||
| B4 | D1.1: one `lifecycleTake` transaction re-checks and writes `lifecycle.taken`, T2a, M4, M22 |
|
||||
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses `instance-starting`, runs under `serialize`, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
|
||||
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
|
||||
| B7 | D2.4: keep covers `organization.set`'s model write, T10, M27 |
|
||||
| B8 | D4.1: `bus.tester` is read from the file at each call and at the take, T12a, M20 |
|
||||
| Q2 | D2.4, T10, M28 |
|
||||
| N1 | D3 item 2: one reload after every committed write |
|
||||
| N2 | D2.5: `noHire` takes any id-pattern name |
|
||||
| N3 | D2.2: hire's vars limited to the model key and narrowing `limits.*` |
|
||||
| N4 | D3 item 4 names the tests that change |
|
||||
| N5 | D7 and T16: `agents.js` joins `serve.mjs:28` and `index.html` |
|
||||
| N6 | V3 and V4: no SIGKILL, `regenerate-timeout` |
|
||||
| N7 | D2.4: intended, Q169 |
|
||||
| N8 | V2 item 2 and T2 |
|
||||
|
||||
## r3 changes against sage-1374, sage-1377 and Megavolt's F4
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| Q4 ruled: `hireable` stays empty, the first kind is a later policy commit in `roles/` | D2.3, R6, Questions |
|
||||
| `instance.name` joins the floor (Webby B2, mos-1023 item 6) | D2.3 table, T8b, T8c, M47, M48 |
|
||||
| F4: fire ends the fired agent's processes, since `ENV_ALLOW` gives every run the shared model token | D2.2, D7, T9a, M49 to M51, R7 |
|
||||
| `git.push.remote` routed to coder only (mos-1028 item 3, row 199 r3) | `PRD-1.7-DRAFT.md`, REQ-LAND-2 and the REQ-WAIT-3 append |
|
||||
|
||||
## r4 changes against webby-0010, sage-1380 and megavolt-0008
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| webby-0010 B1: the deny-list floor becomes the allowlist `HIRE_ALLOWED`, so merge-gate, `decision.resolve.technical`, `policy.change`, `prd.approve` and any later action are refused | D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
|
||||
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
|
||||
| webby-0010 N2: the model write is in REQ-COS-3's text | `PRD-1.7-DRAFT.md` |
|
||||
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
|
||||
| sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) | `PRD-1.7-DRAFT.md` |
|
||||
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
|
||||
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
|
||||
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
|
||||
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
|
||||
| Q4: Webby's view matches Sage's ruling (empty) | no change |
|
||||
@@ -0,0 +1,327 @@
|
||||
# PRD 1.8 draft: trusted actions within role guardrails, the manual mode, and grants the chief of staff
|
||||
# requests and the user approves
|
||||
|
||||
Draft by Honker for Vikunja #434, under Mos's assignment mos-1117 (through Sage, sage-1525). Not approved.
|
||||
It changes nothing until Jason approves it as a `prd.approve` decision. Sage opens that decision and sends
|
||||
Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 008746b6e, plus the version 1.7 text Jason
|
||||
approved (PRD-1.7-DRAFT.md r2, 9ba31b5b3, decision d52febb5, mos-1089). Where 1.7 is not yet written into
|
||||
the PRD file, this draft's anchors name 1.7's sections.
|
||||
|
||||
Revision 2 makes Mos's edits a to f (mos-1124, through Sage, sage-1534) and fixes Duckworth's B1 and B2
|
||||
with notes N1 to N6 (duckworth-0057) on r1 at 3c30f1036. Revision 3 fixes Duckworth's C1 and takes
|
||||
notes N1 and N2 (duckworth-0058) on r2 at 60f5ca8fa, with Gizmoduck's enrollment wording
|
||||
(gizmoduck-0067). Revision 4 makes Mos's rulings on duckworth-0057 (mos-1129, through Sage, sage-1538)
|
||||
on r3 at d16ccdb91: only an Approve creates a grant, one decision for a grant that makes an account, and
|
||||
the arbiter route for `host.sudo`. Revision 5 fixes two stale notes from Duckworth's r4 check
|
||||
(duckworth-0061 N1), with no change to the requirement text. Revision 6 makes Mos's wording for those
|
||||
notes and adds two items for Jason at approval (mos-1138, through Sage, sage-1548), again with no change
|
||||
to the requirement text.
|
||||
|
||||
For Jason at approval: "The main agent or chief of staff" in Q10 is read as one role under two names,
|
||||
the chief of staff. So only the chief raises a grant request, and your assistant passes your own request
|
||||
to the chief. That reading is Mos's (mos-1124). To change it, say so when you approve, and REQ-GRANT-1
|
||||
changes before the text goes into the PRD.
|
||||
|
||||
Also for Jason at approval:
|
||||
|
||||
- Only an Approve creates a grant. A grant written into the business file by hand makes nothing. An agent
|
||||
that runs as the same OS user as the stack can still write the stack's own store. That is the B7
|
||||
residual (Q182 on dragon-lin, #423 on .53 and .54), and this version doesn't close it.
|
||||
- Who decides a root command on another host changes. Once `host.sudo` is routed, a raise for it that
|
||||
cites the spec and isn't within-role goes to the arbiter for its domain, as version 1.7 set for
|
||||
`git.push.remote`, not to you. The grant and its allowlist still limit the command. In manual mode the
|
||||
decision comes to you (REQ-TRUST-2).
|
||||
- Revoke: Q188 says "you, Mos or the tester". "Mos" is read as your assistant (REQ-ROLE-4), limited to
|
||||
grants in its own organization, and the tester may revoke only on a dev instance. That reading is Mos's
|
||||
(mos-1124 d, mos-1132). To change it, say so when you approve, and REQ-GRANT-4 changes before the text
|
||||
goes into the PRD.
|
||||
- The broker enforces only that the chief of staff raised a grant request. The rest of the chain of
|
||||
command is the messaging lists and the role contracts, not a broker check (REQ-GRANT-1).
|
||||
|
||||
## What it changes
|
||||
|
||||
Today an agent acts without a raise only on actions its role file grants within-role, and the role
|
||||
files grant few. Every other use of a tool or command waits on a decision. Row 210 builds `host.sudo` as a
|
||||
hard wait, so Jason would decide each command run on another machine. Grants themselves are hand work:
|
||||
Jason makes every token and key from a runbook (REQ-CRED-1).
|
||||
|
||||
Jason's ruling Q188 turns this around. Roles set what each agent may do, and by default agents do it at
|
||||
will within their sandbox and guardrails. A per-use raise happens only in a manual mode. No agent creates
|
||||
a grant. The chief of staff brings a grant request to the user's inbox, the user clicks Approve or Deny, and
|
||||
protected stack code does every step of making the grant. The user's own tokens are entered once in the
|
||||
WebUI and never reach an agent.
|
||||
|
||||
This version adds:
|
||||
|
||||
1. Trusted actions within role guardrails as the default mode (REQ-TRUST-1).
|
||||
2. Manual mode, a list of actions that each need a decision per use: who sets it, where it lives and what
|
||||
it changes (REQ-TRUST-2).
|
||||
3. Grant requests, raised only by the chief of staff through the chain of command (REQ-GRANT-1).
|
||||
4. Approve and the protected grant code, with three grant methods: machine key, Gitea and GitHub
|
||||
(REQ-GRANT-2).
|
||||
5. The user's tokens, entered in the WebUI and used only by the grant code (REQ-GRANT-3).
|
||||
6. Row 210's REQ-CRED-4, generalized from `host.sudo` to every role-listed action that uses a grant
|
||||
(REQ-CRED-4).
|
||||
7. Instant revoke (REQ-GRANT-4).
|
||||
|
||||
The hard waits stay with the user (Mos, mos-1117): spending money, deleting data, new external accounts,
|
||||
messages outside the fleet and the rest of REQ-WAIT-1. Approving a grant is the user resolving one of
|
||||
those hard waits, `credential.mint`. A grant that makes an account, such as a seat's own Gitea user
|
||||
(Q190), is the same one decision: its method says it makes an account, and the inbox shows the account
|
||||
name before Approve. So no hard wait moves to an agent.
|
||||
|
||||
When this version lands, reviewed commits:
|
||||
|
||||
1. add the manual-mode list to the organization file schema, written only on the human's path
|
||||
(REQ-TRUST-2), and make the broker classify a listed action as needing a decision for each use, before
|
||||
any other routing.
|
||||
2. add the grant request: a decision the chief of staff raises with action `credential.mint`, naming the
|
||||
agent, the method and target, the exact rights and, when the method makes an account, the account
|
||||
name (REQ-GRANT-1). The broker refuses a `credential.mint` raise from any other role. An
|
||||
`account.create` raise isn't a grant request and routes as it does in version 1.7, such as a PM's
|
||||
sign-up for a service.
|
||||
3. add the grant code that runs on a resolved Approve, one method at a time, and the grant record it
|
||||
writes (REQ-GRANT-2, Vikunja #435 and #436).
|
||||
4. add the user's token store, written from the WebUI (or the CLI until the Console knows the signed-in
|
||||
human) and read only by the grant code (REQ-GRANT-3).
|
||||
5. move `host.sudo` into both `ROUTED` lists, as row 199 does for `git.push.remote`, and route it under
|
||||
REQ-CRED-4, with the arbiter route version 1.7 set for `git.push.remote`. Which definitions list it
|
||||
within-role is a separate reviewed role commit in `roles/`.
|
||||
6. update the role contracts that list the hard waits: `roles/chief-of-staff.md:75` raises grant
|
||||
requests with the REQ-GRANT-1 fields, and `roles/pm.md:55` asks the chief of staff for a grant instead
|
||||
of raising `credential.mint` itself (line numbers on origin/refactor). Other roles that list the hard
|
||||
waits get the same line as the PM. A role's messaging list carries the chain of command.
|
||||
|
||||
## Text for the PRD
|
||||
|
||||
Status line, appended to the current paragraph:
|
||||
|
||||
> Version 1.8 records Jason's ruling Q188 of 2026-10-20: trusted actions within role guardrails by
|
||||
> default, a manual mode for per-use raises, and grants the chief of staff requests and the user
|
||||
> approves, approved by Jason in decision <prd.approve decision id>.
|
||||
|
||||
Source, two new bullets above "Version 1.7 source":
|
||||
|
||||
> - Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his answers to Q8 and Q10, in part.
|
||||
> Q8: "I would rather grant the grant right to an agent that runs with strict guardrails. That agent
|
||||
> presents the final authorization for the user. Upon approval, the grant is created programmatically.
|
||||
> I see this as: User configures their PATs, API tokens, authorizations within the WebUI. The agent
|
||||
> presents the request for grant with X rights for another agent. The user reviews, then clicks
|
||||
> Approve/Deny. Click of Approve fires off protected mechanical methods to handle the grants on Github,
|
||||
> Gitea, .... services as needed. The tooling would need to exist and must be guarded." And "Disallowing
|
||||
> the agents to create grants autonomously is a necessary evil for better security. Requiring the user
|
||||
> to manually perform the steps is a no-fly." Q10: "Agents should have tools and command access based on
|
||||
> roles. Those roles dictate the allowed tools and commands. The use of a tool or command should not be
|
||||
> raised to the user unless the system is configured with a manual mode that requires user
|
||||
> interaction. By default I want to have the agents capable of running commands and tools at will
|
||||
> within their sandbox/guardrails. This allows autonomous actions by agents within their lanes. Perhaps
|
||||
> a "Trusted actions within approved guardrails" methodology." And "The main agent or chief of staff
|
||||
> would be responsible for surfacing the request to the user's inbox for approval. Not all agents are
|
||||
> capable or authorized to request directly. It must follow the chain of command properly." He agreed
|
||||
> with Mos's Q10 points 3 to 6: protected stack code does every step on Approve, his tokens are entered
|
||||
> in the WebUI and agents never see them, a command on the approved list runs without a click and any
|
||||
> other is refused, and he, Mos or the tester can revoke a grant at once.
|
||||
> - Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his answer "Q12: O1": each agent seat
|
||||
> gets its own account on his Gitea, created through the Q188 approval path once the Gitea grants exist.
|
||||
|
||||
Under "Decisions", after REQ-DEC-6, a new section:
|
||||
|
||||
> ### Trusted actions
|
||||
>
|
||||
> Added in version 1.8 (Q188).
|
||||
>
|
||||
> - **REQ-TRUST-1.** Agents act within their role guardrails without asking. The default mode is
|
||||
> "trusted actions within approved guardrails". An action a role file grants within-role is done at
|
||||
> will, inside the agent's sandbox, its tool limit and its grants, and is logged. It raises nothing to
|
||||
> the user. An action the file doesn't grant within-role still routes by REQ-DEC-2, and a hard wait
|
||||
> still goes to the user (REQ-WAIT-1). Only a reviewed role change adds an action to a role file, and
|
||||
> REQ-WAIT-2 still keeps every hard wait out of every role file.
|
||||
> - **REQ-TRUST-2.** The user can put chosen actions in manual mode. The organization file holds the list
|
||||
> of actions in manual mode, empty by default. While an action is on the list, every use of it by any
|
||||
> role becomes a decision for that use, raised to the user, and the action waits until the user
|
||||
> resolves it. A decision denied, or not resolved, refuses the action. Each decision is used once. For a
|
||||
> command on another host, the decision names the host, the command and its input. Actions not on the
|
||||
> list are unchanged. Only the user changes the list, through the WebUI or the CLI. The chief of
|
||||
> staff's organization writes (REQ-ROLE-3) can't change it, and no agent can. Every change is logged
|
||||
> with who made it. A change applies to the next use, and a run already in progress keeps no
|
||||
> exemption.
|
||||
|
||||
Under "Credentials", after REQ-CRED-3 (version 1.7):
|
||||
|
||||
> Added in version 1.8 (Q188):
|
||||
>
|
||||
> - **REQ-CRED-4.** An action a role definition lists within-role, and that uses a grant, runs without a
|
||||
> decision for each use only when all of these hold:
|
||||
> - the definition declares the credential kind the action uses;
|
||||
> - a grant names that agent, and that host or service;
|
||||
> - for a command, the command matches the grant's allowlist. A grant lists exact argv prefixes. A
|
||||
> wildcard grant is refused;
|
||||
> - the agent's key or token for that grant is issued and not revoked.
|
||||
>
|
||||
> A command off the allowlist is refused, and adding one takes a new grant request (REQ-GRANT-1). An
|
||||
> action whose definition doesn't list it within-role still needs a decision for each use. Only a
|
||||
> reviewed role change adds an action to a definition. `host.sudo` routes under this requirement.
|
||||
> - **REQ-GRANT-1.** No agent creates or writes a grant. A grant request follows the chain of command,
|
||||
> and only the chief of staff raises it to the user's inbox. Any other role that needs a grant for
|
||||
> itself or for an agent it directs asks the chief of staff, through its chain: a worker role through its
|
||||
> PM, the PM and the CTO directly. The assistant passes the user's own request to the chief of staff
|
||||
> (REQ-ASSIST-1). The request names the agent, the grant method and its target, the exact rights, and a
|
||||
> rotate-by or expiry date. The chief raises one only for an agent whose reviewed role definition
|
||||
> allows that kind of credential, and the broker refuses any other. A grant can't name a wildcard, a
|
||||
> shell or an empty command. The request is a decision with the action `credential.mint`, so it goes
|
||||
> to the user as a hard wait (REQ-WAIT-1). A grant whose method makes an account, such as a seat's own
|
||||
> Gitea user (Q190), is the same one decision: the method in the grant says it makes an account, the
|
||||
> request names the account, and the inbox shows the account name before Approve. The user's one
|
||||
> resolution covers the account and its credential, so the hard-waits list doesn't change. An
|
||||
> `account.create` raise isn't a grant request and routes as version 1.7 has it. The broker checks
|
||||
> only that the chief of staff raised the request. The chain of command before that is in the
|
||||
> messaging lists and the role contracts, not a broker check.
|
||||
> - **REQ-GRANT-2.** The user approves or denies each grant request in the inbox. Approve is the only thing
|
||||
> that creates a grant, and the code uses a grant only when it has a matching Approve record. No agent
|
||||
> creates a grant. A grant written into the business file by hand makes nothing. Row 210's hand-written
|
||||
> grants stay a test path until Vikunja #436a moves `host.run` to grants made by Approve. On Approve,
|
||||
> protected stack code does every step, not an agent and not the user: it records the grant, makes the
|
||||
> key or token, installs it on the machine or creates it on the service, and logs each step. On Deny
|
||||
> nothing is made, and the denial is logged. The grant methods are:
|
||||
> - machine key: a key for one agent on one enrolled host, with the command allowlist of REQ-CRED-4. A
|
||||
> host's identity comes from the host side when the user approves its enrollment, never from a first
|
||||
> connection. A host user with sudo receives a grant only after the user approved that user by name;
|
||||
> - Gitea: a token for one agent, and for an agent seat its own Gitea user (Q190), with an expiry. The
|
||||
> all scope and the admin scope are never granted;
|
||||
> - GitHub: a grant for one agent.
|
||||
>
|
||||
> The Gitea and GitHub rights units come in a later design that Jason approves.
|
||||
>
|
||||
> The approval binds the exact rights the user saw and the target or its enrolled identity. A change to
|
||||
> the agent, its definition, the target, the target's identity or the rights after the raise voids it. An
|
||||
> approval is used once. A grant the code couldn't finish is undone at once on the stack's side, so it
|
||||
> is never used. What it left on the host or service stays visible as pending until it is removed, as
|
||||
> for a revoke (REQ-GRANT-4). A new grant needs a new approval, never an automatic retry. The grant
|
||||
> code runs only on a request the user resolved. A request resolved by an arbiter, the tester or any
|
||||
> agent makes nothing. A `credential.mint` decision raised before version 1.8, or without the fields of
|
||||
> REQ-GRANT-1, makes nothing when it is approved. Until the Console
|
||||
> knows the signed-in human (Vikunja #412), Approve is `mosaic decide` in the CLI (REQ-DEC-3).
|
||||
> - **REQ-GRANT-3.** The user enters their own personal access tokens and API tokens in the WebUI, or the
|
||||
> CLI. Only the grant code reads them, to make grants. No agent ever sees a token value or a key's
|
||||
> private half. Values appear in no event, log, message, prompt or agent file, and no agent can read
|
||||
> them back (REQ-VAR-2). Until the Console knows the signed-in human, they are entered through the
|
||||
> CLI.
|
||||
> - **REQ-GRANT-4.** A grant can be revoked at once by the user, by the assistant for a grant in its own
|
||||
> organization, or by the tester on a dev instance (Q173). A revoke needs no decision and no new trust.
|
||||
> It takes effect at once on the stack's side, without waiting on the network, so the next use is
|
||||
> refused. Removing the key or token at the host or service follows. Until that removal is done it stays
|
||||
> visible as pending. A revoke while the grant is being made stops it, and nothing it made stays usable.
|
||||
> Every revoke is logged with who made it.
|
||||
|
||||
REQ-CRED-1, appended:
|
||||
|
||||
> Since version 1.8, a token or key for a grant is made by the grant code on the user's Approve
|
||||
> (REQ-GRANT-2), not by hand. Tokens that exist stay as they are.
|
||||
|
||||
REQ-WAIT-3, appended:
|
||||
|
||||
> `host.sudo` was added as a hard wait and routes under REQ-CRED-4 since version 1.8.
|
||||
|
||||
Under "Risks and open questions", a new bullet:
|
||||
|
||||
> - Trusted mode lets an agent run its allowed commands at will. On another host, an allowed command that
|
||||
> writes a file as root or starts a shell can plant a key, and the seat sandbox doesn't confine the
|
||||
> network, so root can outlive the grant and a revoke. The allowlist narrows this. A seat sandbox with
|
||||
> its own network namespace closes it. Until then the host's own audit log is the check after the fact
|
||||
> (row 210 design section 6).
|
||||
|
||||
## Notes for the reviewer and for Mos
|
||||
|
||||
- REQ-TRUST-1 restates what REQ-DEC-2 already does for within-role actions ("routine and within-role
|
||||
decisions are logged") and names it as the default mode. It grants nothing new by itself. What widens
|
||||
is the role files, one reviewed commit at a time.
|
||||
- The hard waits stay with the user (mos-1117). REQ-GRANT-1 makes the grant request a `credential.mint`
|
||||
decision, already a hard wait under REQ-WAIT-1, so Approve is the user resolving it. REQ-WAIT-2 stands:
|
||||
no role file grants `credential.mint`. The grant code acts on the user's resolution, not on a role
|
||||
grant. A grant that makes an account, such as a seat's Gitea user under Q190, is the same one
|
||||
`credential.mint` decision. So 1.8 doesn't change the hard-waits list (mos-1124 a, mos-1129 N4).
|
||||
- Manual mode: Sage asked who sets it, where it lives and what it changes. Mos accepted these choices
|
||||
(mos-1124 b), and Jason can change them: a list of actions in the organization file, empty by
|
||||
default, changed only by the user, outside the chief's organization writes. A list rather than one
|
||||
switch follows Gizmoduck's #436 draft section 7 (gizmoduck/machine-grant-design f6b6a04b6), so Jason
|
||||
can make host commands manual without making every action manual. Listing every within-role action
|
||||
gives the whole-organization manual mode. Row 210's per-call `host.run` decision, bound to host, argv
|
||||
and stdin and spent once, is this mode's decision for a host command (Q188, Mos's reading).
|
||||
- Gizmoduck's draft proposes REQ-CRED-5 (section 8) for the machine grant. Its content is in REQ-GRANT-1
|
||||
to REQ-GRANT-4 here, generalized to all three methods, so 1.8 has no REQ-CRED-5. Their bounds in
|
||||
gizmoduck-0062 items 4a to 4g are in the text: the chief raises only for an allowed definition, the
|
||||
approval binds the rights shown, it is used once with no automatic retry, no value is ever seen, revoke
|
||||
is at once on the stack's side with the host or service removal visible until done, every grant has a
|
||||
rotate-by or expiry date, and the host identity comes from the host side. Duckworth has reviewed their
|
||||
design r1 to r4 (duckworth-0056 and duckworth-0060), so a later change there may need a change here.
|
||||
- B1 (duckworth-0057, mos-1129): a grant the code couldn't finish is undone at once on the stack's side.
|
||||
What it left on the host or service stays visible as pending until it is removed, as for a revoke.
|
||||
- B2 (duckworth-0057, mos-1129): Mos chose Duckworth's second option. Only an Approve creates a grant,
|
||||
and the code uses a grant only when it has a matching Approve record. A grant written by hand makes
|
||||
nothing. Jason's Q188 gives the reason: "Disallowing the agents to create grants autonomously is a
|
||||
necessary evil for better security" and "Upon approval, the grant is created programmatically." Row
|
||||
210's hand-written grants stay a test path until #436a moves `host.run` to grants made by Approve. r2
|
||||
and r3 had a hand path in the business file, and r4 takes it out, with r3's load check on hand grants
|
||||
(duckworth-0058 N1) and the note that a revoke never rewrites the user's file (duckworth-0058 N2).
|
||||
The same-uid write to the stack's own store stays the B7 residual, and the top of this draft names it
|
||||
for Jason.
|
||||
- C1 (duckworth-0058, sage-1538): `account.create` is the hard wait for any new external account, not
|
||||
only a grant (REQ-WAIT-1, `vocabulary.mjs:43-46`, `roles/pm.md:55`). It stays as version 1.7 has it.
|
||||
Only `credential.mint` is chief-only, and a grant that makes an account is a `credential.mint`
|
||||
decision (N4), so a PM's sign-up for a service routes as before.
|
||||
- Gizmoduck's machine-grant r2 (eb4c756a1, gizmoduck-0067): enrollment is a request the user approves
|
||||
and the stack runs (mos-1120), and a host user with sudo is enrolled by name, the user's Approve
|
||||
covering that user (mos-1125). REQ-GRANT-2's machine key says both. Their design r3 and r4 (1c5705d6d
|
||||
section 6) give revoke to the user, the assistant in its own organization and the tester on a dev
|
||||
instance, as REQ-GRANT-4 does (mos-1124 d).
|
||||
- N1 (duckworth-0057, mos-1129): the approval binds the target or its enrolled identity.
|
||||
- N2 (duckworth-0057, mos-1129): a revoke during creation stops it, and nothing it made stays usable.
|
||||
- N3 (duckworth-0057, mos-1129): landing step 6 lists the role contract updates (`roles/pm.md:55`,
|
||||
`roles/chief-of-staff.md:75`). A `credential.mint` decision raised before 1.8, or one without the
|
||||
grant fields, makes nothing when it is approved.
|
||||
- N4 (duckworth-0057, mos-1129): a decision carries one action (`a.action`, broker.mjs:1678 on
|
||||
origin/refactor). A grant that makes an account is one `credential.mint` decision: the method in the
|
||||
grant says it makes an account, and the inbox shows the account name before Approve. The user clicks
|
||||
once, and there is no separate `account.create` action for it. Gizmoduck's machine-grant r2 reads it
|
||||
the same way.
|
||||
- N5 (duckworth-0057, mos-1129): `host.sudo` keeps the arbiter route version 1.7 set for
|
||||
`git.push.remote`. A raise for it that cites the spec and isn't within-role goes to the arbiter for
|
||||
its domain (broker.mjs:1677-1678 on origin/refactor), and the grant and its allowlist still limit the
|
||||
command. In manual mode the decision goes to the user. The top of this draft names this for Jason as a
|
||||
change in who decides a root command on another host.
|
||||
- N6 (duckworth-0057, mos-1129): the broker enforces only that the chief of staff raised a grant request.
|
||||
The rest of the chain of command is the messaging lists and the role contracts (step 6 of "When this
|
||||
version lands"). REQ-GRANT-1 says so.
|
||||
- Approving a grant doesn't by itself let a command run without a click. That needs REQ-CRED-4 and
|
||||
`host.sudo` in the routed lists, step 5 of "When this version lands" (gizmoduck-0062 item 4i).
|
||||
- Which roles may request: Q188 says "Not all agents are capable or authorized to request directly. It
|
||||
must follow the chain of command properly." The text makes the chief of staff the only role that raises,
|
||||
the PM and the CTO ask it directly, and worker roles ask through their PM. Jason's words also name "the
|
||||
main agent", but REQ-ASSIST-1 keeps the assistant out of directing agents, so it passes the user's
|
||||
request to the chief rather than raising one. Mos accepted this (mos-1124 c) and reads "the main agent
|
||||
or chief of staff" as one role under two names. That reading is Mos's, so the top of this draft puts it
|
||||
to Jason at approval.
|
||||
- REQ-CRED-4 keeps row 210's conditions (origin/gizmoduck/row210-on-199-r2,
|
||||
agents/gizmoduck/work/queue-210/DESIGN.md:376-392): the definition declares the kind, the grant names the
|
||||
agent, an exact argv-prefix allowlist with wildcards refused, the key issued, and only a reviewed role
|
||||
change adds the action. Row 210 put the grant in a hand-edited business file, which stays a test path until
|
||||
#436a. Under 1.8 the grant is what Approve records, so "the business file grants that agent that host"
|
||||
becomes "a grant names that agent and that host".
|
||||
- Revoke: Q188 says "you, Mos or the tester". Mos is Jason's assistant (REQ-ROLE-4), so the text says the
|
||||
assistant, limited to grants in its own organization, and the tester only on a dev instance (Q173).
|
||||
Mos accepted this with those limits (mos-1124 d).
|
||||
- Approve in the Console needs the Console to know the signed-in human (Q175 phase 2, #412). Until then
|
||||
Approve is `mosaic decide`, which REQ-DEC-3 already allows (mos-1124 e). This draft doesn't take
|
||||
REQ-WEB-2, which stays with the Console decide buttons amendment (Vikunja 19/266). That amendment, or a
|
||||
later version, moves Approve into the Console.
|
||||
- Roles already use a scoped Gitea token (`<read|write>:<category>`, all and admin never granted,
|
||||
`packages/business/src/vocabulary.mjs:105-106` on origin/refactor). It is a likely Gitea unit, not a settled
|
||||
one. Only the machine key is designed so far. Gitea and GitHub are a later #436 draft, and minting a Gitea
|
||||
token needs an admin credential the user enters, which is a design of its own (Gizmoduck's draft section
|
||||
11). GitHub's rights unit isn't settled. So the text leaves both units to a later design Jason approves
|
||||
(mos-1124 f), and keeps only the bounds: an expiry, and never the all or admin scope. The PRD names outcomes
|
||||
and checks, not the grant code's steps, as REQ-CRED-3 does.
|
||||
- Jason expects the inbox Approve and the Gitea and GitHub grants by 10-27 (Q188 point 6). That is a
|
||||
delivery date, so it is in the source and not in the requirement text.
|
||||
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
|
||||
paragraph never edited in place except to append.
|
||||
@@ -1,127 +0,0 @@
|
||||
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
|
||||
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
|
||||
Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.
|
||||
|
||||
## What was built
|
||||
|
||||
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
|
||||
atomic write, the lock and the receipt.
|
||||
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
|
||||
- `scripts/mosaic`: sends `access` to the CLI.
|
||||
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
|
||||
and the limits on peers and subjects.
|
||||
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
|
||||
|
||||
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
|
||||
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
|
||||
|
||||
## The lock file (not in PLAN.md; added at Sage's request)
|
||||
|
||||
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
|
||||
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
|
||||
read, the check, the temp write and the rename. It is removed in a `finally`.
|
||||
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
|
||||
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
|
||||
is written.
|
||||
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
|
||||
refusal names the path and the pid. A human checks that no write is running and removes the file. This
|
||||
is the crash case: the holder died mid-write, and a human clears it.
|
||||
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
|
||||
holder that died in that gap leaves an empty lock; a human removes it.
|
||||
|
||||
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
|
||||
race below is gone, not accepted.
|
||||
|
||||
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
|
||||
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
|
||||
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
|
||||
it, and the second rename silently replaces the first. The revision check did not catch it, because both
|
||||
had read the old revision before either wrote.
|
||||
|
||||
Known limits, stated plainly:
|
||||
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
|
||||
no reclaim. The refusal says which pid and which path.
|
||||
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
|
||||
3. The lock is per access file. It does not cover the bus's own reads.
|
||||
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
|
||||
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
|
||||
The bytes are never mixed.
|
||||
|
||||
Tests for the lock (all in `access.test.mjs`):
|
||||
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
|
||||
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
|
||||
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
|
||||
file, the lock and the receipt are unchanged.
|
||||
- "the lock is mode 0600 while held and removed after a refused write".
|
||||
|
||||
Tests for the write path and the refusals (added in this round):
|
||||
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
|
||||
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
|
||||
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
|
||||
Exit 3, the file path in the message, nothing written.
|
||||
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
|
||||
throws if called, and the file is 0664. The refusal must come first.
|
||||
|
||||
## The human proof, and the bus behavior behind test 10
|
||||
|
||||
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
|
||||
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
|
||||
one function, `proveHuman` in `access.mjs`, for R4 to swap.
|
||||
|
||||
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
|
||||
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
|
||||
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
|
||||
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
|
||||
receipt are unchanged.
|
||||
|
||||
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
|
||||
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
|
||||
|
||||
## Context
|
||||
|
||||
`accessContextFor(system, env)`:
|
||||
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
|
||||
row 192's candidate).
|
||||
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
|
||||
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
|
||||
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
|
||||
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
|
||||
`instances`. A business file that fails to load fails the command.
|
||||
|
||||
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
|
||||
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
|
||||
reads `console` through `checkConsole`.
|
||||
|
||||
## Mutant list (one per refusal; run on Sage's go, before review)
|
||||
|
||||
| # | Mutant | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
|
||||
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
|
||||
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
|
||||
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
|
||||
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
|
||||
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
|
||||
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
|
||||
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
|
||||
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
|
||||
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
|
||||
| M10 | the lock is not taken | "a live holder's lock refuses" |
|
||||
|
||||
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
|
||||
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
|
||||
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
|
||||
|
||||
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
|
||||
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
|
||||
|
||||
## Not done in this row
|
||||
|
||||
- `--peers` and the federation key: row 203.
|
||||
- `access.reload`: row R4.
|
||||
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
|
||||
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
volumes:
|
||||
# Configured runtime state root (from config.json dataRoot).
|
||||
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
|
||||
# The whole control directory is protected against unlink and rename-over replacement.
|
||||
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}/control:/var/lib/mosaic/control:ro
|
||||
# Runtime credential only: pi auth file mounted READ-ONLY.
|
||||
# Never copied into the image.
|
||||
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
|
||||
|
||||
@@ -1455,4 +1455,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-21T22:30:20Z | Huey (gpt-6.1-sol) | T1 item 7 r3, scrooge-0007 R1, Vikunja 463 | isolated session-workspaces root and nesting mutant recorded; R1-only confirmation requested, other r2 findings closed; doc-only checks
|
||||
2026-10-21 | Ludwig | PRD 1.11-1.14 drafts (Vikunja 459, 462, 463, 464) | approved by Della, handed to Sage
|
||||
2026-10-21T22:51:07Z | Sage (Opus 5.5) | PRD 1.13 and 1.14 landing with Gosalyn's C2-01c brief and Case 2 design (51fdfb7e2), rows 264-268 filed (#1769-#1773), Case 2 re-pin to a6ac627c8, Vikunja 463 closed | pushed 6ba8a582e..db1e7732a; requirements tests 3/3 and 3/3 and verbs 31/31 on 5f73b388c and db1e7732a
|
||||
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing, rows 269-272 (264b-264e, #1774-#1777), 268 r2 request, row 191 publish and gate slot | 331597709 and eb9672c91 landed, queue e3c0446f5 pushed; rocko/row191-stoprun-dcd8-20261022 7aa18b0f6 and 81889b58d published as jarvis; 191 full gate on Rocko's slot from 00:17Z
|
||||
|
||||
@@ -276,11 +276,25 @@ scripts/mosaic follow --thread <thread> [--tester]
|
||||
scripts/mosaic stop <run>
|
||||
scripts/mosaic launches off | on [--business <id>]
|
||||
scripts/mosaic launches list [--json]
|
||||
scripts/mosaic halt | resume [--business <id>]
|
||||
scripts/mosaic processing status [--business <id>] [--json]
|
||||
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
|
||||
```
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
selected configuration's data root. The bus stays up for reads and human
|
||||
recovery. `mosaic processing status --json` reports settled, pending or unknown
|
||||
stops; `mosaic resume` checks the current generation and refuses pending stops.
|
||||
Only the existing human proof can write it, independently of channel settings.
|
||||
A missing/invalid state is never off: use halt, then resume; an unsafe control
|
||||
parent needs operator repair. Unknown task/remote outcomes are not retried.
|
||||
Native diagnostic seats remain available. First activation must stop old
|
||||
processing before trusted initialization and align the image/read-only control
|
||||
mount. This is a separate approved window, never a build step.
|
||||
|
||||
The human commands read and resolve through the broker's human transport
|
||||
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
|
||||
an agent run, except the forms that take `--tester` (rows 110, 175 and 184), which
|
||||
|
||||
@@ -12,7 +12,7 @@ Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1a's message filter has landed (mos-1021 B2, FED R8).
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
@@ -34,39 +34,20 @@ gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Split on 2026-10-22 (sage-2288, after morgana-0063) into 193b1a and 193b1b below. The text that stood here
|
||||
chained the whole row on FED R3 and R4, which weren't filed. Only the viewer half needs them.
|
||||
|
||||
## Brief: 193b1a, the message filter
|
||||
|
||||
Chained on row 196's verifier (done). The bus gets the section 5.7 `trail` and `replies` filter over owner,
|
||||
reader and tester sessions, with `#readable` owner-only. No schema change. A hidden `trail` subject returns
|
||||
`[]`, exactly as an unknown id does, and `replies` returns `message-not-found` for both. One test per verb
|
||||
compares the hidden response with the unknown-id response byte for byte. The owner-side governance pin test
|
||||
names each item in the user sentence above. Bus tests and mutants 11 and 12 from section 8, plus mutant 1
|
||||
as "`#readable` returns true" (the pin test kills it) and a mutant that makes a hidden subject
|
||||
distinguishable from an unknown id. FED R3 builds on this row's candidate and extends the filter to user
|
||||
sessions.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b1b, viewer verbs and board refusals
|
||||
|
||||
Chained on 193b1a, FED R3 and FED R4. The bus gets the viewer verb list, per-grant `covers` level checks,
|
||||
board reads refused to users, and the user-side governance pin test. Bus tests and mutants 6 and 10 from
|
||||
section 8.
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3l)
|
||||
|
||||
Chained on 193b1a and 193b1b. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3k to 3l (Sage, 2026-10-20: machine grant row a owns 3i to 3j and
|
||||
the #436b build owns 3j to 3k, so this row builds on #436b's landing). The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8,
|
||||
plus mutant 3 (rule 3 dropped), moved here from 193b1 because only shares can kill it (sage-2288).
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Row: stack-release.sh activate --send and --no-sso, for the Q175 phase 2 window
|
||||
|
||||
Sage, 2026-10-22, for Mos's go mos-1246 (re sage-2285). Gitea #385, Vikunja #412. The window plan is
|
||||
`docs/plans/2026-10-22_sso-phase2-window.md` (draft 2, 92be0db02).
|
||||
|
||||
## Brief: activate --send and --no-sso
|
||||
|
||||
Owner Darkwing. Reviewer Filbert (not Sage or Darkwing, mos-1246 item 1). Gate owner Sage. Base: origin/refactor
|
||||
at this brief's commit. It lands and is re-pinned before the phase 2 window. That re-pin is Q173 work on Mos's
|
||||
go, run by Jason or Mos. If the target copy's schema differs from 3j and needs `--migrate`, it waits for Jason.
|
||||
|
||||
Why: at 02da6a2ae, `scripts/stack-release.sh activate` has `--no-send` but no `--send`, and nothing turns
|
||||
`--sso` off. Without `--sso`, `sso_base` carries it from the pointer, and `check_target` refuses the
|
||||
pointer's copy as `already-active`. So the window can't turn send on with `--sso` (mos-1195), and its
|
||||
same-copy rollback (mos-1245 R1) can't run.
|
||||
|
||||
Send and forward auth (mos-1246 F39): send went off on 10-20 (mos-0972) because, under forward auth, any
|
||||
same-uid process can send as Jason through 127.0.0.1:7332. So send goes only with `--sso`.
|
||||
|
||||
What to build, in `scripts/stack-release.sh` only:
|
||||
1. `activate --send` puts `--send` into RUN_CONSOLE where `webui-service.sh` renders it. It refuses unless
|
||||
the run's Console flags hold `--sso`, given or carried. With `--no-send` it is a usage error. The plan's
|
||||
send line reads `send: on; --send was given`, or `send: carried` when the pointer already holds it.
|
||||
`check_target` lets `--send` name the pointer's copy when the pointer holds no `--send`.
|
||||
2. `activate --no-sso` is allowed only when the pointer's Console flags hold `--sso`. Otherwise it refuses
|
||||
with a named reason. It takes the Console flags from the rollback set of the activation that turned
|
||||
`--sso` on (that set's `show-$CONSOLE_UNIT.txt`, checked against its SHA256SUMS), the way `sso_base`
|
||||
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so
|
||||
(F39 a). The tester follows the pointer. A set it can't read, or a set whose Console already ran with
|
||||
`--sso`, refuses before anything stops. `check_target` lets `--no-sso` name the pointer's copy. With
|
||||
`--sso`, `--send`, a proof pair or `--no-proof`, it is a usage error. The plan prints
|
||||
`sso: off; Console flags from <set>/<file>` and lists every flag it restores.
|
||||
3. Both flags are refused under `--units external`, like the other unit-render flags (row 232).
|
||||
4. The usage text and the header comment name both flags. No other behavior changes.
|
||||
|
||||
Tests, whole files on Sage's slot: `packages/cli/tests/stack-release.test.mjs`,
|
||||
`packages/cli/tests/stack-release-external.test.mjs` for item 3, and `scripts/test-release.sh`.
|
||||
- T1: `--sso --send` on a pointer without send. The plan shows `send: on`, and the unit and the pointer
|
||||
gain `--send`.
|
||||
- T2: `--send` without `--sso`, given or carried, refuses (F39 b). `--send --no-send` is a usage error.
|
||||
`--send` on the same copy when the pointer already has send refuses as `already-active`.
|
||||
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy returns the unit and the pointer to the
|
||||
pre-`--sso` flags byte for byte, with no `--send` (F39 a and c). The tester stays as the pointer had it.
|
||||
- T4: `--no-sso` refusals, each before any unit stops: a pointer without `--sso`; a tampered set
|
||||
(SHA256SUMS mismatch); a set whose Console ran with `--sso`; `--no-sso` combined with `--sso`, `--send`
|
||||
or a proof flag.
|
||||
- T5: `--units external` refuses both flags.
|
||||
|
||||
Mutants, each killed: drop the SHA256SUMS check in the `--no-sso` path; let `--no-sso` run with no `--sso`
|
||||
in the pointer; let `--no-sso` keep `--send`; let `--send` run without `--sso`; let `--send` and
|
||||
`--no-send` combine.
|
||||
|
||||
Gate: Filbert approves the manifest. The two touched test files run whole plus test-release.sh, and the
|
||||
mutants are killed. No live activation in this row. The window uses it.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Brief: row 253 F5 follow-up, supervised legacy AS_USER calls in the case scripts
|
||||
|
||||
Sage, 2026-10-22. Source: row 253's DESIGN-C4 addendum (Gander 968db0bcd, :111-124) and Duckworth's
|
||||
addendum ruling (duckworth-0111, RULING-c4-addendum.md at 45288e584, F5 and C4).
|
||||
|
||||
## Brief: supervised legacy AS_USER calls
|
||||
|
||||
Owner Gander. Reviewer Duckworth. Gate owner Sage. Starts after row 253 lands.
|
||||
|
||||
Why: when a terminal is present, sudo runs the command in a new session (use_pty), and it can't relay
|
||||
SIGKILL. Row 253 puts its new config query and peer probe under an inline supervisor that scans the
|
||||
session it owns. It leaves the older calls in `case1-accept.sh` as they are. These are the `timeout 330 ...
|
||||
talk` path (case1-accept.sh.txt:305 in Gander's frozen snapshot) and the other AS_USER diagnostics (:326,
|
||||
:335, :355, :396). Each of these wraps sudo in a GNU timeout outside the uid drop. That has the same gap:
|
||||
when it fires, it kills timeout's process group, and the command keeps running in its separate session.
|
||||
|
||||
What to build:
|
||||
1. Every remaining AS_USER call in the case scripts runs under row 253's supervisor text. That's the same
|
||||
bytes, pinned by row 253's C5 test, with its own inner and outer bounds and reasons. The talk path
|
||||
keeps its 300 s conversation and its stdout and stderr diagnostics.
|
||||
2. Or, for any call that can't take the supervisor, the header says it isn't supervised and why, and the
|
||||
DEFERRED entry stays open for that call by name.
|
||||
|
||||
Tests: extend case-sudo-fixture.mjs cases in both fake modes (exec and session hop) to each changed call.
|
||||
Each one needs a no-leftover assertion and its exact reason. Mutants: drop the supervisor from the talk path,
|
||||
and drop the outer bound from one diagnostic. Each must be killed.
|
||||
|
||||
Gate: Duckworth approves the manifest, the touched test files run whole, every scripts/test-*.sh runs,
|
||||
and the mutants are killed.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Federation build row R3, the broker user principal (#414)
|
||||
|
||||
Sage, 2026-10-22. The design is Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at b93f098f0 on
|
||||
`gizmoduck/federation-design` (r4.11, sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6).
|
||||
Sections 3.5, 3.6 and 7 set R3's scope and test gate. Vikunja P19 #414. R3's parents, R1 (row 201) and row 196,
|
||||
have landed. I owed this filing when they did and missed it (morgana-0063). Row 205 (193b1a) and 193b1b wait
|
||||
on it.
|
||||
|
||||
## Brief: FED R3, the broker user principal
|
||||
|
||||
Owner Morgana, after 193b1a's candidate goes to review. Reviewer Dewey (section 7: Filbert or Dewey). Gate
|
||||
owner Sage. Base: 193b1a's candidate (row 205), so R3 extends that row's `trail` and `replies` filter to user
|
||||
sessions instead of re-cutting it. If 193b1a changes in review, R3 rebases onto a new branch name.
|
||||
|
||||
What to build, in `packages/bus/src/` (broker.mjs and a new `user-cli.mjs`) plus `packages/harness/src/runner.mjs`
|
||||
for the header line:
|
||||
1. The user proof: a path-bound `packages/bus/src/user-cli.mjs` with its own nonce variable
|
||||
(`MOSAIC_BUS_USER_NONCE`), checked with the `user` kind of row 196's `verifyPrincipal` (section 3.6 checks
|
||||
1-3). R3 consumes that kind. It doesn't re-implement the parent walk.
|
||||
2. `bindUser({ business, user })` and the broker changes 1-7 in section 3.6, including the reply path (B6),
|
||||
`#userThread`, `user-thread-human`, `#waiting` returning null for `to: 'user'`, and the runner header line
|
||||
for `from_role` `user`, word for word.
|
||||
3. The collaborator verb list from section 3.5, exact, with its filters. Every other verb fails
|
||||
`access-refused` before its own checks. The grant is read per request through R1's loader and `covers`
|
||||
with `level: 'collaborator'`.
|
||||
4. The section 3.6 item 5 audit: every `s.human` and `s.tester` branch in broker.mjs, listed in the packet as
|
||||
user refused, user treated as human, or not reachable for a user.
|
||||
5. No revocation timer, `access.reload` or launcher change. Those are R4. No Console change. That is R6.
|
||||
|
||||
Tests, in `packages/bus/tests/` and the runner's tests: the R3 test gate in section 7, item by item. Mutants:
|
||||
the five in section 7's R3 row, plus one that drops the 193b1a filter for user sessions (a user's `trail` of
|
||||
a hidden subject differs from an unknown id's). The agent-parent, non-Console-parent and kind-confusion
|
||||
mutants are row 196's and stay there.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Dewey approves the manifest. The bus and harness suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Plan: Q175 phase 2 window, the live Console switches to --sso
|
||||
|
||||
Sage, 2026-10-22. Draft 2 for Mos, after mos-1245. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
|
||||
and 10 (phase 2 order, rollback, Jason-gated steps), Mos's C1 condition (mos-0977, b416d2ede), Jason's phase 2
|
||||
answers (Q209, mos-1216, mos-1217), mos-1195, mos-1197 and mos-1245. Rows 192 (phase 1), 200 (`activate --sso`)
|
||||
and its N1 follow-up have landed. Vikunja #412, Gitea #385.
|
||||
|
||||
## Blocked on a tool gap
|
||||
|
||||
Two steps need `scripts/stack-release.sh` flags that don't exist at 02da6a2ae:
|
||||
|
||||
- `activate` has `--no-send` but no `--send`. The live pointer holds no `--send`: the 10-20 19:29:56Z
|
||||
re-pin of 490e2771d gave `--no-send`, and every activation since has carried it off. So step 5 can't turn
|
||||
`--send` on (mos-1195).
|
||||
- `activate` has no way to turn `--sso` off. Without `--sso`, `sso_base` carries it from the pointer
|
||||
(`sso: carried`), and `check_target` refuses the pointer's own copy as `already-active`. The pre-window
|
||||
flags (`--public-host`, `--auth-header`, `--allow-user`) have no activate option either. So R1's
|
||||
same-copy rollback can't run.
|
||||
|
||||
The fix is one small row, approved in mos-1246: `activate --send` and `activate --no-sso`. `--no-sso`
|
||||
takes the Console flags from the rollback set of the activation that turned `--sso` on, the way `sso_base`
|
||||
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so. `--send`
|
||||
refuses unless the run's Console flags hold `--sso`, given or carried (F39). The cases for both go in
|
||||
`packages/cli/tests/stack-release.test.mjs`. F39 named test-release.sh, but that suite tests `release.sh`
|
||||
and never calls `stack-release.sh`, so it runs whole and unchanged. Darkwing builds it, and a seat other than Sage and Darkwing reviews it. Brief:
|
||||
`docs/plans/2026-10-22_activate-send-nosso.md`. It lands and is re-pinned before the window, on Mos's go,
|
||||
run by Jason or Mos. The steps below assume it, and draft 3 names the new copy.
|
||||
|
||||
## When
|
||||
|
||||
Only when Jason starts the window (mos-1197). Nothing below runs before his start. The 10-24 re-pin
|
||||
condition is met: W12 passed on 7247d80c7 at 22:27:13Z (mos-1245). This plan names 7247d80c7, which already
|
||||
carries phase 1, so its loader accepts a config with or without `console`. If another re-pin lands before
|
||||
Jason starts, the plan names the copy live at that point, and step 3 runs against it.
|
||||
|
||||
## Who
|
||||
|
||||
- Sage or Darkwing runs the agent steps under Q173: backup first, STATE report after.
|
||||
- Mos or Jason starts and restarts the Console (decision 102).
|
||||
- Jason signs in, sends and signs out.
|
||||
- Config writes 1 and 2 (steps 4 and 8) are an agent's, `console` key only, each with a backup first and
|
||||
a validate after. Mos's message to Jason at the window start names them. If Jason says he'll write them
|
||||
himself, steps 4 and 8 become his and nothing else changes (mos-1245 (a)).
|
||||
|
||||
## Values
|
||||
|
||||
- Provider: the existing Authentik `mosaic-stack` provider (A5). Issuer
|
||||
`https://auth.diversecanvas.com/application/o/mosaic-stack/`. Redirect
|
||||
`https://mosaic.woltje.com/auth/callback`. The Logout URI field holds `/auth/signed-out`.
|
||||
- clientId `fvchAepp9ZGoVq8l7YKej3Gbh5dyf8vNv9ORpDaU` (mos-1217, not a secret).
|
||||
- clientSecret `{ "slot": "console-sso-authentik" }`. The slot file is
|
||||
`~/.config/mosaic-dev/secrets/mosaic-stack/console-sso-authentik`, set to 0600 by Mos at 21:25:06Z on 10-21
|
||||
(A6).
|
||||
- subjects: Jason's sub only, name `jason.woltje` (mos-1197). The loader refuses an empty list
|
||||
(`sso-config.mjs:62`), and the Console reads the config only at start. So the first write carries one
|
||||
placeholder subject. The real sub replaces it after his first sign-in, followed by a restart (A8).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backups: the bus store, the release pointer, the Console unit and config.json, each copied 0600 into
|
||||
a new dated directory, with sha256 recorded. The pointer backup records the pre-window Console flags
|
||||
the rollback restores.
|
||||
2. Slot shape check (A6), without printing or copying the value: `stat` shows mode 0600 and the owner;
|
||||
`wc -l` is 1; `tr -cd '\r' <slot | wc -c` is 0; the last byte is a newline (`tail -c1 | od -An -c`).
|
||||
3. Straggler check (C1): list every running process, unit and timer that reads config.json, with the
|
||||
copy it runs from (`/proc/<pid>/exe` and cwd links, `systemctl --user list-units`, `list-timers`). Each one
|
||||
must run from 7247d80c7, or be shown never to re-read config. Step 4 waits until the list is clean.
|
||||
4. Config write 1 (agent, backup from step 1): add `console.sso` with the values above and one subject
|
||||
`{ "sub": "pending-first-sign-in", "name": "jason.woltje" }`. `scripts/mosaic-config.mjs validate`
|
||||
reads it back before anything starts.
|
||||
5. First `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --sso --send --plan`. Its from line must
|
||||
name 7247d80c7 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
|
||||
usage error). Then the same command without `--plan`, the same way earlier re-pins handled the
|
||||
Console. The tester stays the private socket from row 196 (`--tester-socket`). The `--plan` output
|
||||
names every flag it drops (`--public-host`, `--auth-header`, `--allow-user`) and every flag it carries.
|
||||
The run stops if `--sso` or `--send` is missing or a forward-auth flag remains. Mos or Jason starts the
|
||||
Console. No agent starts it.
|
||||
6. The start line shows one provider, the issuer and `subjects 1`. No secret value, no sub.
|
||||
7. Jason's first sign-in (A8). It refuses `subject-unknown`. An agent reads `sub=` from that one log line,
|
||||
checks that `iss=` is the issuer above, and records that the refused sub is not the placeholder. The sub
|
||||
goes into the window record, not into any message.
|
||||
8. Config write 2 (agent, new backup first): replace the placeholder with Jason's sub, so the list holds
|
||||
that one subject only. Validate. Mos or Jason restarts the Console. The start line shows `subjects 1`.
|
||||
9. Jason signs in, sends one message and signs out.
|
||||
10. Unsigned write refused (mos-1195), within Q173 (mos-1245 (c)): an agent acts as an anonymous client,
|
||||
not as Jason. It POSTs one Console write to `127.0.0.1:7332` with no cookie and no auth header, and
|
||||
the body names a tester-owned thread, so a wrong 2xx reaches no live seat. It expects 401
|
||||
`sign-in required` with the bus event count unchanged, and it writes nothing else. No agent sends
|
||||
`X-authentik-username` to the live Console (design section 8).
|
||||
11. Forged header refused: on a scratch Console from the same copy (`serve --sso --port <scratch>`,
|
||||
`MOSAIC_CONFIG` at a scratch config with a scratch dataRoot and a dummy slot, issuer an unreachable
|
||||
loopback host, no `--send` and no tester), a request with `X-authentik-username` and no cookie
|
||||
gets 401. Mos or Jason may run the same check on live instead (B6).
|
||||
12. Sign-out landing (A5): after step 9, did Authentik land on `/auth/signed-out`, or did it refuse
|
||||
`post_logout_redirect_uri`? `sso.mjs` deletes the session before redirecting, so a miss changes only the
|
||||
landing page. On a miss, Mos turns it into one small ask to Jason, and the window still counts as passed.
|
||||
13. STATE report: pointer, copy, schema, Console flags from the unit, the start line, results of steps 7-12,
|
||||
backup paths and digests. Gitea #385 gets the STATE comment. The `--sso` flag carries forward on later
|
||||
re-pins (mos-1197).
|
||||
|
||||
## Stops
|
||||
|
||||
- Any refusal in steps 2-6 or 8: stop, keep the backups, and roll back as below. Never edit around it.
|
||||
- While the placeholder is the only subject, any sign-in that creates a session is a stop and a
|
||||
rollback (mos-1245 (b)).
|
||||
- Step 10: any result other than 401 with the event count unchanged is a stop, and it goes to Mos.
|
||||
- Two failures in the window: stop and report to Mos (mos-0823).
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --no-sso
|
||||
--plan`, then the same without `--plan`. R1 asked for `--expect-from 7247d80c7`, but `--expect-from` is
|
||||
for `--units external` only and the live units are user units, so the plan's from line must name
|
||||
7247d80c7 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
|
||||
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`, exactly.
|
||||
`--no-sso` drops `--send` (mos-1246 F39), so there's no `--send`. A plan that differs is a stop. Mos or Jason restarts the Console.
|
||||
2. Restore config.json from the step 1 backup, which removes `console`. 7247d80c7 loads config with or
|
||||
without the key, so the order of 1 and 2 doesn't matter on this copy.
|
||||
3. STATE after the rollback reads config.json, the unit's Console flags and the pointer.
|
||||
4. Last resort only, and it waits for Jason: `scripts/stack-release.sh rollback` goes to the previous
|
||||
distinct copy, 4429a104f at schema 3i. The store has been at 3j since 21:59Z on 10-21, so that rollback
|
||||
needs `--restore`, and any events after the backup make it Jason's call (header condition 3).
|
||||
@@ -380,6 +380,47 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
credential and a real `release.sh activate`, and records both results on
|
||||
the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244
|
||||
r1 (#1757). (2026-10-21)
|
||||
- **Creative instances: the human can't delete one.** (Sage's ruling
|
||||
sage-2164 on the row 194 b4 rebase, #1692.) Row 213 put the creative
|
||||
definitions in END_PROTECTED, and row 194's `deleteRefusal`
|
||||
(`instance-settings.mjs`) refuses END_PROTECTED for every actor, so
|
||||
the human can't delete a companion instance and fire refuses one too.
|
||||
The Agents page hides Delete on the listing's `endProtected`. The fix
|
||||
threads the actor through `deleteRefusal` and the launcher's delete
|
||||
pass, so the human may delete one and the tester may not
|
||||
(`creativeHumanOnly`). Until then, the human can delist
|
||||
(instances.set launch: false) but neither fire nor delete removes a
|
||||
creative entry (sage-2166). The row covers fire and delete together;
|
||||
Sage files it. (2026-10-21)
|
||||
- **A fire stuck between step A and step B has no human escape.** (Webby
|
||||
N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The
|
||||
broker's chief fire records `fire.stopped` at step A, and `#firing`
|
||||
refuses every launch and take of the instance until a `fire.done`. The
|
||||
events are in the store, so a bus restart doesn't clear them. Row 194
|
||||
b6 runs the writer's checks against the file before step A
|
||||
(`instance.fireCheck`), so a keep the bus hasn't loaded no longer
|
||||
strands an instance. A step B that keeps refusing still does: a
|
||||
credential revoke that fails every time, or a file a human changed
|
||||
between the check and the write. Each repeat fire runs step A again
|
||||
and refuses at B. The fix is a human verb, never an agent one, that
|
||||
records the end of the firing (a `fire.done`, or a new
|
||||
`fire.abandoned` that `#firing` also counts) with a reason. Its test:
|
||||
a fire whose step B refuses leaves the instance `instance-firing`, the
|
||||
verb as the chief or the tester refuses, and the verb as the human
|
||||
clears it so the instance launches again. (2026-10-21)
|
||||
- **A fire's done with more than 64 stops refuses, and nothing flags a
|
||||
run left alive.** (Webby N2 on row 194 b8 r2, webby-0100; Sage's ruling
|
||||
sage-2290, #1692.) `lifecycleDone` caps `stops` at 64 and refuses
|
||||
`invalid-request` above it (`broker.mjs`). The launcher's fireStop has
|
||||
already signalled every run through stopRun by then, so the killed runs
|
||||
are dead. But the refused done writes no lifecycle.end.killed or
|
||||
lifecycle.end.alive event and no lifecycle.done, and the end request
|
||||
stays taken. A run the stop left alive, or couldn't confirm (unknown),
|
||||
gets no Console flag. The fire itself is unaffected, because
|
||||
`fire.stopped` and `fire.done` are written at the verb. No MVP instance
|
||||
comes near 64 runs. The follow-up row either raises the cap or splits
|
||||
the done. Its test: a fire's done over the cap refuses, and the runs it
|
||||
left alive still get the alive flag. (2026-10-22)
|
||||
|
||||
## Queue
|
||||
|
||||
|
||||
+7
-15
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3072 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -238,11 +238,11 @@ Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not e
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 2, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | in-progress | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not e
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
|
||||
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
|
||||
@@ -300,19 +300,11 @@ Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not e
|
||||
| 261 | Email E05: Graph source module against a test-local registry (post-v1) | unassigned | #1766 | queued | 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E05 Graph source module against a test-local registry | — |
|
||||
| 262 | Email E06: IMAP source module (post-v1) | unassigned | #1767 | queued | 261 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E06 IMAP source module | — |
|
||||
| 263 | Email E07: address and rule evaluator (post-v1) | unassigned | #1768 | queued | 258 done, 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E07 address and rule evaluator | — |
|
||||
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | in-progress | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat). |
|
||||
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | briefed | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat). |
|
||||
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | rocko; reviewers dewey | #1770 | queued | 219 done | Dewey approves the manifest. The brief's tests run whole plus the bus suite and every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09b rooms: room.open, room.send with @tags, room.read | — |
|
||||
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
|
||||
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
|
||||
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | in-review, round 2, request posted | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
|
||||
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | in-progress | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | — |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | in-progress | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
|
||||
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
|
||||
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
|
||||
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | in-review, round 1, request posted | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+27
-1496
File diff suppressed because it is too large
Load Diff
@@ -935,3 +935,25 @@ narrow cross-instance reach, not reach within one instance.
|
||||
|
||||
The Console provenance option trusts verified code and process metadata, not an independent
|
||||
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
|
||||
|
||||
## Durable instance processing control
|
||||
|
||||
`processing.status` is an authorized read. `processing.halt` ({}) and
|
||||
`processing.resume` ({generation}) require the existing human proof before
|
||||
validation or effects. They are management verbs independent of channels and
|
||||
never tester or role grants. The strict state under the resolved dataRoot's
|
||||
control/ directory is authoritative; reserved processing.halt/resume/stop
|
||||
actions mirror its durable transitions and write-once stop receipts with
|
||||
verified human and generation. Generic event writers cannot forge them. No
|
||||
schema change is needed. Invalid state refuses work and leaves control reads
|
||||
and human recovery reachable. Halt then resume repairs an invalid leaf; an
|
||||
unsafe parent is not automatically repaired.
|
||||
|
||||
The bus remains a read/control service while new agent work, tracker polling,
|
||||
notifications and launches pause. Narrow exit cleanup remains possible. Every
|
||||
managed starter carries a generation and publishes identity before releasing
|
||||
work. Stops inspect root/boot/start identity or generated container id/labels,
|
||||
TERM concurrently, then KILL after five seconds and observe for two more.
|
||||
Unknown targets remain visible and prevent resume. Existing run evidence is
|
||||
not synthesized or rewritten. First activation stops old processing before
|
||||
trusted initialization and aligns the copy and image in the human's window.
|
||||
|
||||
+644
-48
@@ -1,9 +1,10 @@
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { isAbsolute, dirname } from 'node:path';
|
||||
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -33,6 +34,9 @@ export const ACTIONS = Object.freeze([
|
||||
'organization.change',
|
||||
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
|
||||
'instance.name',
|
||||
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
'credential.mint',
|
||||
'spend',
|
||||
'deploy',
|
||||
@@ -68,6 +72,9 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
]);
|
||||
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
@@ -116,7 +123,9 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
|
||||
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
|
||||
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
|
||||
'instance.launch', 'instance.end', 'instance.request']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -125,6 +134,39 @@ export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'in
|
||||
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
|
||||
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
|
||||
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
|
||||
// Row 194 (#1692, D4): the human lifecycle verbs, one entry per verb and instances.set op. Each calls
|
||||
// #mayHuman after #human, and the hook refuses a verb missing here. Row 193 maps each entry to the
|
||||
// human roles that may use it.
|
||||
export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance.end', 'instance.regenerate', 'veto.set',
|
||||
'instances.set add', 'instances.set change', 'instances.set delete']);
|
||||
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
|
||||
// action.allowed events, which event.emit refuses for any action but routine.
|
||||
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
|
||||
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
|
||||
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
|
||||
// limit: the launcher checks each run's start time before it signals.
|
||||
export const LIFECYCLE_REQUEST_MS = 60_000;
|
||||
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
|
||||
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
|
||||
const FIRE_STOPS = ['killed', 'alive'];
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
|
||||
// required. Each carries the reason the log records.
|
||||
const HIRE_FIRE_ARGS = Object.freeze({
|
||||
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
|
||||
'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.
|
||||
const lifecycleView = (r) => ({
|
||||
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 } : {}),
|
||||
...('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, ...(r.done.bot ? { bot: r.done.bot } : {}) } : null,
|
||||
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
|
||||
});
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -168,7 +210,9 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
|
||||
// Row 155 (#1649) adds assistants: an assistant instance's vars, tracker and definition.
|
||||
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
|
||||
// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs).
|
||||
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
|
||||
// Row 194 (brief item 7, sage-1610 a): add takes tracker, an existing Vikunja bot.
|
||||
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch', 'tracker'];
|
||||
const VETO_KEYS = ['revision', 'op', 'name'];
|
||||
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
|
||||
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
|
||||
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
|
||||
@@ -262,6 +306,36 @@ export const THREAD_PAGE_MAX = 200;
|
||||
// A project or thread name: what the user calls it, one line.
|
||||
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
|
||||
|
||||
// The checks a business document passes at boot and at every reload.
|
||||
function checkBusiness(name, b) {
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
const all = [...a.withinRole, ...a.crossRole];
|
||||
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
fail('invalid-arbiter');
|
||||
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
|
||||
for (const d of Object.values(b.definitions ?? {})) {
|
||||
const m = d?.messaging;
|
||||
if (m === undefined || m === null) continue;
|
||||
if (
|
||||
!object(m) ||
|
||||
!['sendTo', 'receiveFrom'].every(
|
||||
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
|
||||
)
|
||||
)
|
||||
fail('invalid-messaging');
|
||||
}
|
||||
}
|
||||
|
||||
export class Broker {
|
||||
#store;
|
||||
#businesses;
|
||||
@@ -293,14 +367,24 @@ export class Broker {
|
||||
#hostUndo = [];
|
||||
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
|
||||
#revokeUndo = [];
|
||||
#testerOn;
|
||||
#roleFile;
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
|
||||
const dataRoot = dirname(store.directory);
|
||||
try { initializeControl(dataRoot); } catch (e) {
|
||||
if (!e.code?.startsWith('processing-')) throw e;
|
||||
// Keep the control socket and the human's conservative halt repair available.
|
||||
}
|
||||
this.#processing = processing ?? { read: () => processingStatus(dataRoot) };
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
@@ -317,6 +401,15 @@ export class Broker {
|
||||
// module-shaped name passes and the launcher's own harness-off check is the only one.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
this.#harnessOn = harnessOn ?? (() => true);
|
||||
// Row 194 (#1692, D4.1, B8): testerOn(business) is true when the organization file as it stands
|
||||
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
|
||||
// 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');
|
||||
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.
|
||||
this.#requirements = loadRequirements();
|
||||
for (const table of [
|
||||
@@ -339,33 +432,93 @@ export class Broker {
|
||||
this.#clock = Math.max(this.#clock, ms);
|
||||
}
|
||||
}
|
||||
for (const [name, b] of Object.entries(this.#businesses)) {
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
const all = [...a.withinRole, ...a.crossRole];
|
||||
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b);
|
||||
}
|
||||
// Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would
|
||||
// load it. It is synchronous, so no request sees half of it. A new human is row 193's ground.
|
||||
reloadBusiness(name, document) {
|
||||
if (!Object.hasOwn(this.#businesses, name)) fail('unknown-business');
|
||||
if (!object(document)) fail('invalid-business');
|
||||
const next = structuredClone(document);
|
||||
checkBusiness(name, next);
|
||||
if (next.human !== this.#businesses[name].human) fail('invalid-business');
|
||||
this.#businesses[name] = next;
|
||||
}
|
||||
#processingState() {
|
||||
try {
|
||||
const state = this.#processing.read();
|
||||
if (!object(state) || (state.invalid === true
|
||||
? ![true, null].includes(state.halted) || typeof state.code !== 'string' || !state.code.startsWith('processing-')
|
||||
: typeof state.halted !== 'boolean' || state.version !== 1 || typeof state.generation !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(state.generation)))
|
||||
fail('processing-state-invalid');
|
||||
return state;
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code?.startsWith('processing-')) return { halted: true, invalid: true, code: e.code };
|
||||
fail('processing-state-invalid');
|
||||
}
|
||||
}
|
||||
#processingWork() {
|
||||
const state = this.#processingState();
|
||||
if (state.invalid) fail(state.code);
|
||||
if (state.halted) fail('processing-halted');
|
||||
return state;
|
||||
}
|
||||
#processingGate(s, verb) {
|
||||
if (s.human || s.reader || s.tester) return;
|
||||
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
|
||||
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
|
||||
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status',
|
||||
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
|
||||
}
|
||||
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
|
||||
recordProcessingStop(stop) {
|
||||
if (typeof this.#processing.stopReceipts !== 'function') fail('not-configured');
|
||||
const state = this.#processingState();
|
||||
if (!state.halted || state.invalid || stop?.generation !== state.generation) fail('reserved-event');
|
||||
const stored = this.#processing.stopReceipts(state.generation).find(r => r.id === stop.id);
|
||||
if (!stored || JSON.stringify(stored) !== JSON.stringify(stop)) fail('reserved-event');
|
||||
this.#store.transaction(() => {
|
||||
const body = { action: 'processing.stop', human: state.human, scope: 'instance', ...stored };
|
||||
const old = this.#store.get('SELECT body FROM events WHERE id=?', stored.id);
|
||||
if (old) {
|
||||
if (old.body !== JSON.stringify(body)) fail('processing-audit-conflict');
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
fail('invalid-arbiter');
|
||||
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
|
||||
for (const d of Object.values(b.definitions ?? {})) {
|
||||
const m = d?.messaging;
|
||||
if (m === undefined || m === null) continue;
|
||||
if (
|
||||
!object(m) ||
|
||||
!['sendTo', 'receiveFrom'].every(
|
||||
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
|
||||
)
|
||||
)
|
||||
fail('invalid-messaging');
|
||||
this.#event({ business: state.business, role: null, run: null }, 'action.allowed', body, stored.runtime, stored.id);
|
||||
});
|
||||
}
|
||||
#processingMirror(state) {
|
||||
if (!state?.operation || state.invalid) return;
|
||||
const action = state.halted ? 'processing.halt' : 'processing.resume';
|
||||
this.#store.transaction(() => {
|
||||
const old = this.#store.get('SELECT business,kind,body FROM events WHERE id=?', state.operation);
|
||||
if (old) {
|
||||
const body = JSON.parse(old.body);
|
||||
if (old.business !== state.business || old.kind !== 'action.allowed' || body.action !== action ||
|
||||
body.operation !== state.operation || body.human !== state.human) fail('processing-audit-conflict');
|
||||
return;
|
||||
}
|
||||
this.#event({ business: state.business, role: null, run: null }, 'action.allowed', {
|
||||
action, operation: state.operation, generation: state.generation, human: state.human,
|
||||
at: state.at, recovered: state.recovered, scope: 'instance',
|
||||
}, state.generation, state.operation);
|
||||
});
|
||||
}
|
||||
async #requestProcessing(s, verb, args) {
|
||||
try {
|
||||
this.#human(s);
|
||||
keys(args, verb === 'processing.halt' ? [] : ['generation'], verb === 'processing.halt' ? [] : ['generation']);
|
||||
const method = verb === 'processing.halt' ? 'halt' : 'resume';
|
||||
if (typeof this.#processing[method] !== 'function') fail('not-configured');
|
||||
this.#processingMirror(this.#processingState());
|
||||
const result = await this.#processing[method]({ ...args, business: s.business, human: s.human });
|
||||
this.#processingMirror(result);
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw this.#refused(s, e.code?.startsWith('processing-') ? new BusError(e.code) : e, { verb, args }, 'outcome-unknown');
|
||||
}
|
||||
}
|
||||
#now() {
|
||||
@@ -403,6 +556,97 @@ export class Broker {
|
||||
owner,
|
||||
);
|
||||
}
|
||||
// Row 194 (#1692): the runs that hold a claim for an instance, one per thread for a thread instance.
|
||||
#running(business, instance) {
|
||||
if (this.#threaded(business, instance)) return this.#threadHolders(business, instance).map((h) => h.holder_run);
|
||||
const h = this.#holder(business, instance);
|
||||
return h?.op === 'claim' ? [h.holder_run] : [];
|
||||
}
|
||||
// Row 194 (#1692, D1.1): a lifecycle request and how far it got, or null. The request is the
|
||||
// action.allowed event the verb wrote. Its taken, done and cancelled events name it by id.
|
||||
#lifecycle(business, request) {
|
||||
const row = this.#store.get(
|
||||
"SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?,?)",
|
||||
business, request, ...LIFECYCLE_REQUESTS);
|
||||
if (!row) return null;
|
||||
const steps = this.#store.all(
|
||||
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?) ORDER BY seq",
|
||||
business, request, ...LIFECYCLE_STEPS).map((e) => JSON.parse(e.body));
|
||||
const step = (action) => steps.find((x) => x.action === action) ?? null;
|
||||
const done = step('lifecycle.done'), cancelled = step('lifecycle.cancelled'), taken = step('lifecycle.taken') !== null;
|
||||
return { ...JSON.parse(row.body), at: row.at, instance: row.subject, taken, done, cancelled,
|
||||
state: done || cancelled ? 'closed' : taken ? 'taken' : 'open' };
|
||||
}
|
||||
// The requests not yet closed, oldest first, of one instance or of the whole business.
|
||||
#openRequests(business, instance = null) {
|
||||
return this.#store.all(
|
||||
`SELECT json_extract(r.body,'$.request') request FROM events r WHERE r.business=? AND r.kind='action.allowed'
|
||||
AND json_extract(r.body,'$.action') IN (?,?,?,?) AND (? IS NULL OR r.subject=?)
|
||||
AND NOT EXISTS (SELECT 1 FROM events c WHERE c.business=r.business AND c.kind='action.allowed'
|
||||
AND json_extract(c.body,'$.request')=json_extract(r.body,'$.request') AND json_extract(c.body,'$.action') IN ('lifecycle.done','lifecycle.cancelled'))
|
||||
ORDER BY r.seq`,
|
||||
business, ...LIFECYCLE_REQUESTS, instance, instance).map((r) => this.#lifecycle(business, r.request));
|
||||
}
|
||||
#expired(r) {
|
||||
return Math.max(Date.now(), this.#clock) - Date.parse(r.at) >= LIFECYCLE_REQUEST_MS;
|
||||
}
|
||||
// V5 instance-starting: a launch, regenerate or delete of I that is taken, or open and still young
|
||||
// enough to be taken. An open request past its 60 s can only close as request-expired.
|
||||
#starting(business, instance) {
|
||||
return this.#openRequests(business, instance).some((r) => r.action !== 'lifecycle.end' && (r.state === 'taken' || !this.#expired(r)));
|
||||
}
|
||||
// The verb's request event, with a human.input for the human's own, as launch.revoke writes. A
|
||||
// tester request records `tester` and has no human input.
|
||||
#lifecycleRecord(s, action, instance, extra = {}) {
|
||||
const request = randomUUID();
|
||||
const eid = this.#event(s, 'action.allowed', { action, request, via: s.tester ? 'tester' : s.via, by: this.#owner(s), ...extra }, instance);
|
||||
if (!s.tester) this.#event(s, 'human.input', { kind: 'admin', event: eid });
|
||||
return request;
|
||||
}
|
||||
// A step the launcher's host ops write, or a terminate's cancel. No session acts in it.
|
||||
#lifecycleStep(business, action, body) {
|
||||
this.#event({ business, role: null, run: null }, 'action.allowed', { action, ...body });
|
||||
}
|
||||
// D4.1: a tester lifecycle call, and the take of a tester request, need bus.tester exactly true in
|
||||
// the file as it stands.
|
||||
#testerFile(business) {
|
||||
if (this.#testerOn(business) !== true) fail('tester-off');
|
||||
}
|
||||
// V2 and V4 at the verb. The take repeats the revoked, retired and halted checks. D6: row 191's
|
||||
// switch refuses a launch and a regenerate, the human's and the tester's too.
|
||||
#launchable(s, instance, thread) {
|
||||
this.#processingWork();
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
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.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#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 d.subject=f.subject
|
||||
AND json_extract(d.body,'$.action')='fire.done' AND d.seq>f.seq) LIMIT 1`,
|
||||
business, instance);
|
||||
}
|
||||
// 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.
|
||||
#takeRefusal(business, r) {
|
||||
if (r.action === 'lifecycle.end') return null;
|
||||
if (this.#expired(r)) return 'request-expired';
|
||||
if (r.action !== 'lifecycle.delete') {
|
||||
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.#firing(business, r.instance)) return 'instance-firing';
|
||||
// D6: row 191's switch, read as the take runs.
|
||||
try { this.#processingWork(); }
|
||||
catch (e) { return e.code ?? 'processing-state-invalid'; }
|
||||
}
|
||||
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
|
||||
return null;
|
||||
}
|
||||
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
|
||||
#own(s) {
|
||||
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
|
||||
@@ -475,6 +719,12 @@ export class Broker {
|
||||
if (s.reader) fail('read-only');
|
||||
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
|
||||
}
|
||||
// Row 194 (#1692, D4): until row 193, a lifecycle verb passes for the business's one human, the
|
||||
// owner. Row 193 replaces this body and nothing else.
|
||||
#mayHuman(s, verb, target) {
|
||||
if (!LIFECYCLE_HUMAN_VERBS.includes(verb) || typeof target !== 'string') fail('human-required');
|
||||
if (s.human !== this.#business(s.business).human) fail('human-required');
|
||||
}
|
||||
#cap(s) {
|
||||
const cap = randomBytes(32).toString('hex');
|
||||
this.#sessions.set(cap, Object.freeze(s));
|
||||
@@ -581,6 +831,7 @@ export class Broker {
|
||||
};
|
||||
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
|
||||
// harness was switched off, so it can be ended.
|
||||
if (!old) this.#processingWork();
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (old) {
|
||||
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
||||
@@ -654,7 +905,7 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
return { business: s.business, role: s.role, run: s.run, human: s.human || null };
|
||||
}
|
||||
#event(s, kind, body, subject = null) {
|
||||
#event(s, kind, body, subject = null, eid = randomUUID()) {
|
||||
this.#secretCheck({ kind, body, subject });
|
||||
if (
|
||||
(kind.startsWith('action.') || kind.startsWith('review.')) &&
|
||||
@@ -662,7 +913,6 @@ export class Broker {
|
||||
subject !== (body.task_ref ?? body.target)
|
||||
)
|
||||
fail('task-subject-required');
|
||||
const eid = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
eid,
|
||||
@@ -1599,6 +1849,7 @@ export class Broker {
|
||||
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||
}
|
||||
#checkAuthority(s, action, context = {}) {
|
||||
this.#processingWork();
|
||||
this.#agent(s);
|
||||
return this.#authorityFor(s, action, context);
|
||||
}
|
||||
@@ -1610,6 +1861,7 @@ export class Broker {
|
||||
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
|
||||
)
|
||||
fail('launch-revoked');
|
||||
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
|
||||
const cls = this.#classification(s, action, target);
|
||||
if (cls === 'within-role' && !decision) return { class: cls };
|
||||
if (!decision) fail('decision-required');
|
||||
@@ -1716,6 +1968,7 @@ export class Broker {
|
||||
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
|
||||
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
|
||||
checkPush(cap, task_ref) {
|
||||
this.#processingWork();
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
@@ -1752,6 +2005,7 @@ export class Broker {
|
||||
// project the landing uses. A refusal is a code; nothing is recorded unless it refuses.
|
||||
// The ticket names the landing to recordLand and landRefused, which may run after the run ended.
|
||||
checkLand(cap, args) {
|
||||
this.#processingWork();
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
keys(args, ['task_ref', 'decision'], ['task_ref', 'decision']);
|
||||
@@ -2011,6 +2265,97 @@ export class Broker {
|
||||
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
|
||||
return { released: true };
|
||||
}
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
|
||||
// lifecycleRequests lists the requests not yet closed, oldest first.
|
||||
lifecycleRequests(business) {
|
||||
this.#business(identifier(business));
|
||||
return this.#openRequests(business).map(lifecycleView);
|
||||
}
|
||||
// The take, in one transaction: an open request that passes #takeRefusal is taken and answers go,
|
||||
// one that fails it closes with the code. A taken request is never taken again.
|
||||
lifecycleTake(record) {
|
||||
keys(record, ['business', 'request'], ['business', 'request']);
|
||||
this.#business(identifier(record.business));
|
||||
identifier(record.request);
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
|
||||
if (r.state !== 'open') return { go: false, refusal: 'request-closed', ...lifecycleView(r) };
|
||||
const refusal = this.#takeRefusal(record.business, r);
|
||||
this.#lifecycleStep(record.business, refusal ? 'lifecycle.done' : 'lifecycle.taken', { request: r.request, ...(refusal ? { refusal } : {}) });
|
||||
return { go: !refusal, refusal, ...lifecycleView(this.#lifecycle(record.business, r.request)) };
|
||||
});
|
||||
}
|
||||
// V5 step 2 for a taken delete: the running and starting checks again, then the writer's delete
|
||||
// against the file as it stands, with the human's revision. The bus reloads after the commit, as
|
||||
// after every write. A retry of a retired name commits nothing and goes to the rename, with the
|
||||
// transaction of the delete whose rename failed (row 194 S3), which names the rename's target.
|
||||
lifecycleDelete(record) {
|
||||
keys(record, ['business', 'request'], ['business', 'request']);
|
||||
this.#business(identifier(record.business));
|
||||
const r = this.#lifecycle(record.business, identifier(record.request)) ?? fail('unknown-request');
|
||||
if (r.action !== 'lifecycle.delete' || r.state !== 'taken') fail('request-not-taken');
|
||||
if (r.retry === true) {
|
||||
const failed = this.#store.all(
|
||||
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' AND json_extract(body,'$.request')<>? ORDER BY seq DESC",
|
||||
record.business, r.instance, r.request).map((x) => this.#lifecycle(record.business, x.request))
|
||||
.find((x) => x.done?.refusal === 'workspace-rename-failed');
|
||||
return { transaction: failed?.done?.transaction ?? null, retry: true };
|
||||
}
|
||||
if (this.#running(record.business, r.instance).length > 0) fail('instance-running');
|
||||
if (this.#openRequests(record.business, r.instance).some((o) => o.request !== r.request && o.action !== 'lifecycle.end' &&
|
||||
(o.state === 'taken' || !this.#expired(o)))) fail('instance-starting');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
let out;
|
||||
try {
|
||||
out = this.#organization(record.business, 'instances.set', { revision: r.revision, instance: r.instance, op: 'delete' }, r.by);
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, 'instances.set');
|
||||
}
|
||||
// sage-1610 (b): a deleted agent's Vikunja bot is named, so the human removes it in Vikunja.
|
||||
return { transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) };
|
||||
}
|
||||
// The close of a taken request, with the run it started, the delete's transaction or the code
|
||||
// that stopped it. A delete also names the agent's Vikunja bot (sage-1610 b). A fire's end
|
||||
// (kill: true) also reports each run row 191's stop killed or left alive (D2.2, S6), and each is
|
||||
// written as lifecycle.end.killed or lifecycle.end.alive against the instance. An alive stop
|
||||
// whose kill stopRun couldn't confirm carries reason unknown (sage-2289).
|
||||
lifecycleDone(record) {
|
||||
keys(record, ['business', 'request', 'run', 'transaction', 'refusal', 'bot', 'stops'], ['business', 'request']);
|
||||
if ('bot' in record && (typeof record.bot !== 'string' || !BOT_NAME.test(record.bot))) fail('invalid-request');
|
||||
this.#business(identifier(record.business));
|
||||
identifier(record.request);
|
||||
for (const k of ['run', 'transaction']) if (k in record) identifier(record[k]);
|
||||
if ('refusal' in record && !LIFECYCLE_CODE.test(record.refusal)) fail('invalid-request');
|
||||
if ('stops' in record) {
|
||||
if (!Array.isArray(record.stops) || record.stops.length > 64) fail('invalid-request');
|
||||
for (const x of record.stops) {
|
||||
keys(x, ['run', 'pid', 'outcome', 'reason'], ['run', 'pid', 'outcome']);
|
||||
identifier(x.run);
|
||||
if (!FIRE_STOPS.includes(x.outcome) || !(x.pid === null || (Number.isSafeInteger(x.pid) && x.pid > 1))) fail('invalid-request');
|
||||
// sage-2289: only an alive stop carries a reason, and only unknown.
|
||||
if ('reason' in x && !(x.reason === 'unknown' && x.outcome === 'alive')) fail('invalid-request');
|
||||
}
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
|
||||
if (r.state !== 'taken') fail(r.state === 'open' ? 'request-not-taken' : 'request-closed');
|
||||
const { business, stops = [], ...body } = record;
|
||||
if (stops.length > 0 && !(r.action === 'lifecycle.end' && r.kill === true)) fail('invalid-request');
|
||||
for (const x of stops)
|
||||
this.#event({ business, role: null, run: null }, 'action.allowed', { action: `lifecycle.end.${x.outcome}`, request: r.request, run: x.run, pid: x.pid, ...('reason' in x ? { reason: x.reason } : {}) }, r.instance);
|
||||
this.#lifecycleStep(business, 'lifecycle.done', body);
|
||||
return lifecycleView(this.#lifecycle(business, r.request));
|
||||
});
|
||||
}
|
||||
// D7: the runs a fire's stop left alive that haven't ended since, which the Console flags.
|
||||
#aliveAfterFire(business, instance) {
|
||||
return [...new Set(this.#store.all(
|
||||
`SELECT json_extract(a.body,'$.run') run FROM events a WHERE a.business=? AND a.kind='action.allowed' AND a.subject=?
|
||||
AND json_extract(a.body,'$.action')='lifecycle.end.alive'
|
||||
AND NOT EXISTS (SELECT 1 FROM events e WHERE e.business=a.business AND e.kind='session.ended' AND e.subject=json_extract(a.body,'$.run'))
|
||||
ORDER BY a.seq`,
|
||||
business, instance).map((x) => x.run))];
|
||||
}
|
||||
// The merge decisions landHeld would count, each with its raised context and closing event. A
|
||||
// null role is every non-thread instance's (row 156): raised outside a thread by a role that
|
||||
// isn't a thread instance.
|
||||
@@ -2029,6 +2374,7 @@ export class Broker {
|
||||
// Row 93: what the git socket's fetch for a reviewer may send, from the review.requested event the
|
||||
// reviewer cites. The caller must hold review.verdict and be the request's reviewer. Trusted host only.
|
||||
reviewFetch(cap, { task_ref, request } = {}) {
|
||||
this.#processingWork();
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
@@ -2056,7 +2402,7 @@ export class Broker {
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && ['git.push.working', MERGE].includes(body?.action)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && ['git.push.working', MERGE, 'processing.halt', 'processing.resume', 'processing.stop'].includes(body?.action)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
|
||||
return this.#event(s, kind, body, subject);
|
||||
});
|
||||
@@ -2179,6 +2525,13 @@ export class Broker {
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
|
||||
return this.#requestProcessing(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'processing.status') {
|
||||
keys(args, []);
|
||||
return this.#processingState();
|
||||
}
|
||||
this.#processingGate(s, request.verb);
|
||||
// Row 213 (C2-08, Case 2 design 5.1 M6): a creative role has no free-text decision channel. It
|
||||
// runs before the spec-cited branch below, so a cited raise refuses too.
|
||||
if (request.verb === 'decision.raise' && !s.human && !s.tester &&
|
||||
@@ -2199,7 +2552,9 @@ export class Broker {
|
||||
if (LIBRARY_VERBS.includes(request.verb))
|
||||
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
|
||||
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)) {
|
||||
// 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
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -2216,13 +2571,42 @@ export class Broker {
|
||||
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
|
||||
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
|
||||
// organization.set and the chief of staff's tool never reach it (sage-0918).
|
||||
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
|
||||
else if (request.verb === "instances.set") {
|
||||
keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
|
||||
if (!["add", "change", "delete"].includes(args.op)) fail("invalid-request");
|
||||
// Row 194 (D4.1, B6): the tester never deletes.
|
||||
if (s.tester && args.op === "delete") fail("tester-refused");
|
||||
if (!s.tester) {
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, `instances.set ${args.op}`, args.instance);
|
||||
}
|
||||
// A delete is a launcher request (V5): the checks here write nothing, and the launcher's
|
||||
// pass commits the file and renames the workspace after the take.
|
||||
if (args.op === "delete") return this.#deleteRequest(s, args);
|
||||
// Row 194 (D3 item 4): the write now goes live at once, so a live cap's authority must not
|
||||
// change under its run.
|
||||
if (args.op === "change" && "definition" in args && typeof args.instance === "string" &&
|
||||
Object.hasOwn(this.#business(s.business).roles, args.instance) && this.#running(s.business, args.instance).length > 0)
|
||||
fail("instance-running");
|
||||
}
|
||||
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
|
||||
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
|
||||
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
|
||||
// runs the same route checks.
|
||||
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
|
||||
else keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (#1692, D2.4, V6): only the human writes launch.keep and launch.noHire. The tester
|
||||
// is refused by the gate above, as veto.set isn't in TESTER_ORGANIZATION_VERBS.
|
||||
else if (request.verb === "veto.set") {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
// the next launch. The human's limits.set still can.
|
||||
const keep = this.#business(s.business).launch?.keep ?? [];
|
||||
if (role && object(args.instances) && Object.keys(args.instances).some(i => keep.includes(i))) fail("instance-kept");
|
||||
}
|
||||
let actor = s.tester ? "tester" : s.human;
|
||||
// This branch runs outside the store transaction below, so the grant is checked before the writer
|
||||
// and consumed only once the writer has committed: a refused change logs action.refused alone,
|
||||
@@ -2237,20 +2621,17 @@ export class Broker {
|
||||
? { revision: this.#organization(s.business, "limits", {}, actor).revision }
|
||||
: this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
|
||||
} catch (e) {
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||
// 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);
|
||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
throw new BusError("outcome-unknown");
|
||||
this.#organizationFail(e, request.verb);
|
||||
}
|
||||
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
|
||||
this.#secretCheck(result);
|
||||
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims, and
|
||||
// (D7) the runs a fire left alive. An answer with no instances map passes through as it did before.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
|
||||
for (const [name, entry] of Object.entries(result.instances)) {
|
||||
entry.running = this.#running(s.business, name);
|
||||
entry.alive = this.#aliveAfterFire(s.business, name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -2299,6 +2680,160 @@ export class Broker {
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
|
||||
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
|
||||
// transaction, so the grant is checked before it and the use logged 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, required);
|
||||
this.#hireFireArgs(action, a);
|
||||
const args = a;
|
||||
const b = this.#business(s.business);
|
||||
if (b.launch?.by !== s.role) fail('role-not-chief');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
|
||||
// (decision-required), and these verbs take none.
|
||||
const authority = { target: args.name };
|
||||
this.#checkAuthority(s, action, authority);
|
||||
// D2.4: a kept name is neither hired nor fired.
|
||||
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, reason: args.reason };
|
||||
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
|
||||
const { reason: _, ...written } = args;
|
||||
const write = (op) => {
|
||||
try {
|
||||
return this.#organization(s.business, op, written, 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, { reason: args.reason, 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');
|
||||
// webby-0094 B1: the same checks against the file. A keep this copy hasn't loaded (a reload the
|
||||
// credentials refused) would otherwise pass here, and step B's refusal would leave I firing.
|
||||
write('instance.fireCheck');
|
||||
// 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', null, 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.
|
||||
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', 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: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
|
||||
this.#store.transaction(() => {
|
||||
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
||||
});
|
||||
return { ...result, ...stopped };
|
||||
}
|
||||
// The shape of hire and fire arguments.
|
||||
#hireFireArgs(action, args) {
|
||||
identifier(args.name);
|
||||
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
|
||||
string(args.reason, 500);
|
||||
if (action === 'role.fire') return;
|
||||
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
|
||||
// outcome-unknown: the write may have committed.
|
||||
#organizationFail(e, verb) {
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) 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") && e.code === "definition-not-hireable") fail(e.code);
|
||||
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
|
||||
if (e.code === "instance-kept") fail(e.code);
|
||||
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
|
||||
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
|
||||
// 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);
|
||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||
if (verb === "routes.set" && e.code === "unknown-model") fail(e.code);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
throw new BusError("outcome-unknown");
|
||||
}
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, in the design's order, then its request.
|
||||
// The writer's instances.check runs deleteRefusal then the last-listed check, with no write. Its
|
||||
// named refusals come first. The last-listed one (a BusinessError) waits until the broker's
|
||||
// running and starting checks have passed. A retired name is a retry of the workspace rename, and
|
||||
// only after a rename that failed.
|
||||
#deleteRequest(s, args) {
|
||||
keys(args, ["revision", "instance", "op"], ["revision", "instance", "op"]);
|
||||
identifier(args.instance);
|
||||
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) fail("invalid-request");
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(args.instance) && !Object.hasOwn(b.roles, args.instance)) {
|
||||
const last = this.#store.get(
|
||||
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' ORDER BY seq DESC LIMIT 1",
|
||||
s.business, args.instance);
|
||||
if (this.#lifecycle(s.business, last?.request ?? "")?.done?.refusal !== "workspace-rename-failed") fail("organization-instance-retired");
|
||||
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision, retry: true })) };
|
||||
}
|
||||
let lastListed = null, revision = null;
|
||||
try {
|
||||
({ revision } = this.#organization(s.business, "instances.check", { instance: args.instance }, s.human));
|
||||
} catch (e) {
|
||||
if (e.name !== "BusinessError") this.#organizationFail(e, "instances.set");
|
||||
lastListed = e;
|
||||
}
|
||||
if (this.#running(s.business, args.instance).length > 0) fail("instance-running");
|
||||
if (this.#starting(s.business, args.instance)) fail("instance-starting");
|
||||
if (lastListed) fail("invalid-request");
|
||||
if (revision !== args.revision) fail("revision-conflict");
|
||||
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision })) };
|
||||
}
|
||||
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
|
||||
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
|
||||
// needs the decision held for the run's role; its end needs that begin, since the landing may
|
||||
@@ -2479,6 +3014,7 @@ export class Broker {
|
||||
async requestTask(cap, request, handler) {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
const args = request.args ?? {};
|
||||
@@ -2516,6 +3052,7 @@ export class Broker {
|
||||
let args;
|
||||
try {
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
this.#processingWork();
|
||||
if (!verb) fail('unknown-verb');
|
||||
args = request.args ?? {};
|
||||
const field = verb === 'web.search' ? 'query' : 'url';
|
||||
@@ -2544,7 +3081,8 @@ export class Broker {
|
||||
let out;
|
||||
try {
|
||||
onAdmit();
|
||||
out = await service.handle(verb, args);
|
||||
out = await (typeof this.#processing.work === 'function'
|
||||
? this.#processing.work(() => service.handle(verb, args)) : service.handle(verb, args));
|
||||
} catch {
|
||||
complete({ outcome: 'failed' });
|
||||
throw refuse(new BusError('web-failed'));
|
||||
@@ -2571,6 +3109,7 @@ export class Broker {
|
||||
};
|
||||
let a, entry, timeout, bound;
|
||||
try {
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
a = request.args ?? {};
|
||||
@@ -2707,6 +3246,9 @@ export class Broker {
|
||||
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
|
||||
if (a.role === s.role) fail('role-end-refused');
|
||||
this.#protectedEnd(s, a.role);
|
||||
// Row 194 (#1692, D2.4): a kept instance's run ends only by a decision the human resolved,
|
||||
// the human's own end or the limits. End then launch would be a replace.
|
||||
if ((this.#business(s.business).launch?.keep ?? []).includes(a.role)) fail('instance-kept');
|
||||
} else {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
@@ -3422,6 +3964,60 @@ export class Broker {
|
||||
this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human);
|
||||
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
|
||||
}
|
||||
// Row 194 (#1692, D1.1, V2, V4): launch and regenerate are requests the launcher's pass takes.
|
||||
// The tester launches (D4.1) and never regenerates: instance.regenerate isn't in TESTER_VERBS.
|
||||
case 'instance.launch':
|
||||
case 'instance.regenerate': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['instance'], ['instance']);
|
||||
identifier(a.instance);
|
||||
if (s.tester) this.#testerFile(s.business);
|
||||
else this.#mayHuman(s, verb, a.instance);
|
||||
this.#launchable(s, a.instance, verb === 'instance.launch' ? 'not-thread-launch' : 'regenerate-thread');
|
||||
const action = verb === 'instance.launch' ? 'lifecycle.launch' : 'lifecycle.regenerate';
|
||||
return { request: this.#lifecycleRecord(s, action, a.instance) };
|
||||
}
|
||||
// V3: every claim of I is revoked, and every open request of I cancelled, in this transaction.
|
||||
// lifecycle.end names the runs for the launcher's pass to signal, the bound runs that hold no
|
||||
// claim included. A tester ends only its own threads of a thread instance, as assistant.stop
|
||||
// does, and cancels only its own requests.
|
||||
case 'instance.end': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['instance', 'reason'], ['instance']);
|
||||
identifier(a.instance);
|
||||
if ('reason' in a) string(a.reason, 500);
|
||||
if (s.tester) this.#testerFile(s.business);
|
||||
else this.#mayHuman(s, verb, a.instance);
|
||||
if (!Object.hasOwn(b.roles, a.instance)) fail('unknown-role');
|
||||
const owner = s.tester ? 'tester' : null;
|
||||
const threaded = this.#threaded(s.business, a.instance);
|
||||
const held = threaded
|
||||
? this.#threadHolders(s.business, a.instance, owner)
|
||||
: [this.#holder(s.business, a.instance)].filter((h) => h?.op === 'claim');
|
||||
const reason = a.reason ?? (s.tester ? 'ended by the tester' : 'ended by the human');
|
||||
for (const h of held) this.#claimEnd(s, h, 'revoke', null, reason, this.#owner(s));
|
||||
const runs = held.map((h) => h.holder_run);
|
||||
for (const [key, r] of this.#runs)
|
||||
if (r.business === s.business && r.role === a.instance && !this.#ended.has(key) && !runs.includes(r.run) &&
|
||||
(!owner || !r.thread || this.#store.get('SELECT owner FROM threads WHERE id=?', r.thread)?.owner === owner))
|
||||
runs.push(r.run);
|
||||
const open = this.#openRequests(s.business, a.instance).filter((r) => r.action !== 'lifecycle.end' && (!owner || r.by === owner));
|
||||
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
|
||||
// A taken request is past cancelling, and the verb answers it in `taken`. The pass signals a
|
||||
// run it spawned by the end's time. A run spawned after the verb is the next end's (N6).
|
||||
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
|
||||
if (runs.length === 0 && cancelled.length === 0 && taken.length === 0) fail('not-held');
|
||||
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: this.#owner(s) });
|
||||
return { request: this.#lifecycleRecord(s, 'lifecycle.end', a.instance, { runs, reason }), runs, cancelled, taken };
|
||||
}
|
||||
// D1.1 item 5: the read the CLI and the Console poll. A tester reads only its own requests.
|
||||
case 'instance.request': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['request'], ['request']);
|
||||
const r = this.#lifecycle(s.business, identifier(a.request));
|
||||
if (!r || (s.tester && r.by !== 'tester')) fail('unknown-request');
|
||||
return lifecycleView(r);
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human && !s.tester) this.#agent(s);
|
||||
|
||||
@@ -27,7 +27,7 @@ export class Client {
|
||||
socket.destroy();
|
||||
error ? reject(error) : resolve(value);
|
||||
};
|
||||
socket.setTimeout(this.timeout, () => end(new BusError('outcome-unknown')));
|
||||
socket.setTimeout(['processing.halt', 'processing.resume'].includes(verb) ? Math.max(this.timeout, 20000) : this.timeout, () => end(new BusError('outcome-unknown')));
|
||||
socket.on('connect', () => socket.write(request));
|
||||
socket.on('error', () => end(new BusError('outcome-unknown')));
|
||||
socket.on('end', () => end(new BusError('outcome-unknown')));
|
||||
|
||||
@@ -25,6 +25,10 @@ const LAUNCH_OPS = new Set([
|
||||
'mayLaunch',
|
||||
'landHolders',
|
||||
'landRelease',
|
||||
'lifecycleRequests',
|
||||
'lifecycleTake',
|
||||
'lifecycleDelete',
|
||||
'lifecycleDone',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
if (m.op === 'identity') return runtime.identity(m.cap);
|
||||
@@ -55,6 +59,11 @@ function launchOp(m) {
|
||||
// role run started before its decision was resolved.
|
||||
if (m.op === 'landHolders') return runtime.landHolders(m.business);
|
||||
if (m.op === 'landRelease') return runtime.landRelease(m.record);
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
|
||||
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
|
||||
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
|
||||
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
|
||||
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
|
||||
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
|
||||
|
||||
+177
-19
@@ -13,8 +13,13 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter } from '../../business/src/organization.mjs';
|
||||
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { busBusiness } from './business.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
@@ -23,6 +28,12 @@ import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
import { createLimits } from './limits.mjs';
|
||||
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
|
||||
import { discordProcessingHooks } from '../../discord/src/processing.mjs';
|
||||
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
|
||||
|
||||
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
|
||||
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Human limits writes use the shared organization writer behind the verified transport.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
@@ -91,7 +102,7 @@ export async function startBroker({
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
throw new BusError('invalid-host-request');
|
||||
let store, credentials, server, adapter, syncTimer = null, syncPass = null;
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null;
|
||||
try {
|
||||
const references = {};
|
||||
for (const [business, b] of Object.entries(businesses)) {
|
||||
@@ -126,8 +137,43 @@ export async function startBroker({
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
|
||||
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
|
||||
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
|
||||
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
|
||||
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
|
||||
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
|
||||
// file unchanged.
|
||||
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
|
||||
function revoke(business, agent, how, services) {
|
||||
const instance = business + '/' + agent;
|
||||
const outside = [];
|
||||
try {
|
||||
if (how === 'delete' && storeRoot) credentials.remove(instance);
|
||||
else
|
||||
for (const service of services) {
|
||||
try {
|
||||
if (!credentials.revoke(instance, service).file) outside.push(service);
|
||||
} catch (e) {
|
||||
if (e.code !== 'credential-unknown') throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw revokeFailed(`${agent}: ${e.code ?? 'credential-file'}`);
|
||||
}
|
||||
if (storeRoot) {
|
||||
let left = [];
|
||||
try {
|
||||
left = readdirSync(join(storeRoot, business, agent));
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw revokeFailed(`${agent}: store directory can't be read`);
|
||||
}
|
||||
if (left.length > 0) throw revokeFailed(`${agent}: ${left.sort().join(' and ')} still in the store`);
|
||||
}
|
||||
// A file or env reference outside the store keeps its token for a restart (row 199 section 6).
|
||||
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
|
||||
}
|
||||
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels}):null;
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke}):null;
|
||||
const usage = await createUsage({ store, dataRoot, businesses,
|
||||
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
||||
@@ -138,11 +184,20 @@ export async function startBroker({
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
let broker;
|
||||
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
|
||||
onTransition: () => broker.reconcileProcessing(),
|
||||
onStop: stop => broker.recordProcessingStop(stop),
|
||||
log: line => process.stderr.write(`${line}\n`),
|
||||
});
|
||||
// C2-04: mirrors are host state, never session checkouts. An unsafe
|
||||
// existing mirror refuses startup before a socket can become ready.
|
||||
for (const business of Object.values(businesses))
|
||||
for (const [project, entry] of Object.entries(business.projects ?? {}))
|
||||
if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot });
|
||||
if (entry.kind === 'library') {
|
||||
try { await processing.work(() => libraryModule.ensureMirror({ business, project, dataRoot })); }
|
||||
catch (e) { if (!e.code?.startsWith('processing-')) throw e; }
|
||||
}
|
||||
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
|
||||
// checks that it is there; the askpass helper reads it.
|
||||
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
|
||||
@@ -150,10 +205,54 @@ export async function startBroker({
|
||||
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
});
|
||||
const broker = new Broker({
|
||||
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
|
||||
// would load it. The credential references, the project roots the Credentials store protects
|
||||
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
|
||||
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
|
||||
// answers restart: true. Hire adds neither. A human create of a credentialed definition adds
|
||||
// references, so it answers restart: true, and issue works after the restart (brief item 7).
|
||||
// A role not in the boot copy matches only with no references and no bot. A reference compares
|
||||
// by its kind and path only: row 199's issue rewrites the date in the file outside this reload,
|
||||
// Credentials keeps its own copy of the date, and the broker never reads it.
|
||||
const fixedRef = ({ file, env, store }) => ({ file, env, store });
|
||||
const fixedRole = r => canonicalJson({
|
||||
credentials: Object.fromEntries(Object.entries(r?.credentials ?? {}).map(([service, ref]) => [service, fixedRef(ref)])),
|
||||
tracker: r?.tracker ?? null,
|
||||
});
|
||||
const fixedAtStart = b => ({
|
||||
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
|
||||
sync: canonicalJson(b.tracker?.sync ?? null),
|
||||
roots: canonicalJson(Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map(p => p.root).sort()),
|
||||
// A library entry has no root, and its mirror is ensured at start (C2-04), so each one is fixed whole.
|
||||
libraries: canonicalJson(Object.fromEntries(Object.entries(b.projects ?? {}).filter(([, p]) => p.kind === 'library'))),
|
||||
});
|
||||
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
|
||||
&& fixedAtStart(b).libraries === boot.libraries
|
||||
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
|
||||
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = busBusiness(b, resolved);
|
||||
if (!sameFixed(booted.get(id), next)) {
|
||||
reloadLog({ event: 'reload-needs-restart', business: id });
|
||||
return false;
|
||||
}
|
||||
broker.reloadBusiness(id, next);
|
||||
return true;
|
||||
} catch (e) {
|
||||
reloadLog({ event: 'reload-failed', business: id, message: e.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
broker = new Broker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
processing,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -161,12 +260,12 @@ export async function startBroker({
|
||||
library: {
|
||||
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
|
||||
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
|
||||
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
|
||||
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
|
||||
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
|
||||
@@ -174,19 +273,37 @@ export async function startBroker({
|
||||
},
|
||||
// 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.
|
||||
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.set')return withTokens(id,settings.set(id,args,actor));
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
if(verb==='instances')return instances.read(id);
|
||||
if(verb==='instances.set')return instances.set(id,args,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
|
||||
const result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
|
||||
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
|
||||
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
|
||||
let result;
|
||||
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
|
||||
else if(verb==='instances.set')result=instances.set(id,args,actor);
|
||||
else if(verb==='routes.set')result=settings.setRoutes(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.
|
||||
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 reason 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');
|
||||
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
|
||||
const live=reload(id);
|
||||
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
||||
return result;
|
||||
// Row 194 b6 (webby-0094 B1, sage-2241): every instance-writer verb says whether the reload
|
||||
// took, so a refused one (credentials, roots, ...) shows the restart line for a keep or a
|
||||
// hire too. The other writers' answers carry no restart field.
|
||||
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
|
||||
}: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),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
|
||||
@@ -204,7 +321,23 @@ export async function startBroker({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
|
||||
testerOn: (id) => {
|
||||
try {
|
||||
const b = settings ? settings.current(id) : businesses[id];
|
||||
return b?.vars?.['bus.tester'] === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
broker.reconcileProcessing();
|
||||
processing.start();
|
||||
const processingWork = () => {
|
||||
const state = processing.read();
|
||||
if (state.invalid) throw new BusError(state.code);
|
||||
if (state.halted) throw new BusError('processing-halted');
|
||||
};
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
function bindLaunch(r) {
|
||||
@@ -222,7 +355,7 @@ export async function startBroker({
|
||||
const bound = launches.map(bindLaunch);
|
||||
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
|
||||
if (tasks) {
|
||||
adapter = await tasks({ broker, credentials, businesses });
|
||||
adapter = await tasks({ broker, credentials, businesses, processing });
|
||||
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
|
||||
throw new BusError('invalid-adapter');
|
||||
}
|
||||
@@ -241,9 +374,18 @@ export async function startBroker({
|
||||
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
|
||||
};
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
|
||||
// signal with processing cancellation without changing its proof or credential entry points.
|
||||
const host = runRoot ? hostExecutor({ root: runRoot, credentials: {
|
||||
busy: (...args) => credentials.busy(...args),
|
||||
useFor: (session, name, fn, options) => credentials.useFor(session, name, (token, meta) =>
|
||||
fn(token, { ...meta, signal: meta.signal
|
||||
? AbortSignal.any([meta.signal, processing.signal()]) : processing.signal() }), options),
|
||||
} }) : null;
|
||||
server = await serve({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
host: runRoot ? hostExecutor({ credentials, root: runRoot }) : null,
|
||||
host: host ? { busy: (...args) => host.busy(...args),
|
||||
run: (...args) => processing.work(() => host.run(...args)) } : null,
|
||||
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
broker,
|
||||
path,
|
||||
@@ -297,11 +439,11 @@ export async function startBroker({
|
||||
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
|
||||
const entry = { state: 'running' };
|
||||
remotes.set(ticket, entry);
|
||||
withGitea(ticket, (token) =>
|
||||
processing.work(() => withGitea(ticket, (token) =>
|
||||
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
|
||||
throw e instanceof WorkingError ? new BusError(e.code) : e;
|
||||
}),
|
||||
).then(
|
||||
)).then(
|
||||
(r) => Object.assign(entry, { state: 'pushed', ...r }),
|
||||
(e) => {
|
||||
// Not a refusal: the error's name and code only, never a message that could carry git's output.
|
||||
@@ -374,7 +516,13 @@ export async function startBroker({
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = () => { if (!syncPass) syncPass = syncAll().finally(() => { syncPass = null; }); };
|
||||
const tick = () => {
|
||||
const state = processing.read();
|
||||
if (!syncPass && !state.halted && !state.invalid)
|
||||
syncPass = processing.work(syncAll).catch(e => {
|
||||
if (!e.code?.startsWith('processing-')) syncLog({ event: 'library-sync-refused', code: 'library-sync-failed' });
|
||||
}).finally(() => { syncPass = null; });
|
||||
};
|
||||
if (timed.length) {
|
||||
tick();
|
||||
syncTimer = setInterval(tick, librarySync?.intervalMs ?? libraryModule.SYNC_INTERVAL_MS);
|
||||
@@ -390,7 +538,10 @@ export async function startBroker({
|
||||
threadsState: (business) => broker.threadsState(business),
|
||||
threadIdle: (r) => broker.threadIdle(r),
|
||||
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
||||
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
|
||||
mayLaunch: async ({ business, instance, provider, model, account = null }) => {
|
||||
processingWork();
|
||||
return limits.mayLaunch(business, { instance, provider, model, account });
|
||||
},
|
||||
identity: (cap) => broker.identity(cap),
|
||||
authorizeLaunch,
|
||||
checkPush,
|
||||
@@ -409,6 +560,11 @@ export async function startBroker({
|
||||
// Row 156 (#1651): the launcher's landing pass.
|
||||
landHolders: (business) => broker.landHolders(business),
|
||||
landRelease: (r) => broker.landRelease(r),
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
|
||||
lifecycleRequests: (business) => broker.lifecycleRequests(business),
|
||||
lifecycleTake: (r) => broker.lifecycleTake(r),
|
||||
lifecycleDelete: (r) => broker.lifecycleDelete(r),
|
||||
lifecycleDone: (r) => broker.lifecycleDone(r),
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
launches: bound,
|
||||
@@ -417,6 +573,7 @@ export async function startBroker({
|
||||
await stopSync();
|
||||
await server.close();
|
||||
await adapter?.close?.();
|
||||
await processing?.close();
|
||||
store.close();
|
||||
credentials.close();
|
||||
},
|
||||
@@ -424,6 +581,7 @@ export async function startBroker({
|
||||
} catch (e) {
|
||||
await server?.close();
|
||||
await adapter?.close?.();
|
||||
await processing?.close();
|
||||
store?.close();
|
||||
credentials?.close();
|
||||
throw e;
|
||||
|
||||
@@ -134,8 +134,9 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Row138: only project Git effects need an await; preserve every other sync path below.
|
||||
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
// Async effects retain the synchronous path for every other verb.
|
||||
if (r.verb === 'processing.halt' || r.verb === 'processing.resume' ||
|
||||
r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
|
||||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
|
||||
HOSTS_VERBS.includes(r.verb) ||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// every call and labels every reply untrusted (broker.mjs, requestWeb).
|
||||
|
||||
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
|
||||
import { currentProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
|
||||
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
|
||||
@@ -28,6 +29,7 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
|
||||
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
|
||||
// search-source module gets the signal as the `signal` of its last argument.
|
||||
const within = (start) => {
|
||||
const processing = currentProcessing(); processing?.guard();
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const late = new Promise((_, reject) => {
|
||||
@@ -36,7 +38,8 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
|
||||
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
|
||||
}, deadlineMs);
|
||||
});
|
||||
return Promise.race([start(controller.signal), late]).finally(() => clearTimeout(timer));
|
||||
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
|
||||
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
|
||||
};
|
||||
async function run(verb, args) {
|
||||
if (verb === "web.fetch") {
|
||||
|
||||
@@ -21,7 +21,9 @@ const baselines = {
|
||||
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
|
||||
// transport change. Row 155 (#1649) added eight for the assistant routes.
|
||||
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb",
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -7,9 +7,13 @@ import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
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 { 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
|
||||
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
|
||||
@@ -27,7 +31,7 @@ const roles = {
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none },
|
||||
};
|
||||
const demo = () => ({
|
||||
const demo = (extra = {}) => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
@@ -38,12 +42,13 @@ const demo = () => ({
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
vars: { 'bus.tester': true },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
|
||||
|
||||
function setup(t) {
|
||||
function setup(t, extra = {}, { roleFile = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -51,13 +56,16 @@ function setup(t) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const organization = [];
|
||||
const writer = { error: null };
|
||||
const writer = { error: null, during: null };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: demo(),
|
||||
businesses: demo(extra),
|
||||
roleFile,
|
||||
organization: (...args) => {
|
||||
organization.push(args);
|
||||
if (writer.during) writer.during(args);
|
||||
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.
|
||||
return args[1] === 'limits'
|
||||
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
|
||||
@@ -645,3 +653,444 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
);
|
||||
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
|
||||
// reason or change its model. A decision the human resolved, and the human's own write, still can.
|
||||
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
|
||||
|
||||
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
|
||||
const { call, lastClaim, allowed } = setup(t, KEPT);
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
|
||||
assert.equal(allowed('role.end').length, 0);
|
||||
// An instance not on the list still ends with a reason.
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
|
||||
});
|
||||
|
||||
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
|
||||
const { call, lastClaim } = setup(t, KEPT);
|
||||
const d = call('chief', 'decision.raise', {
|
||||
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
});
|
||||
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
|
||||
});
|
||||
|
||||
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
|
||||
const { call, organization, allowed } = setup(t, KEPT);
|
||||
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
|
||||
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('organization.change').length, 0);
|
||||
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
|
||||
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
|
||||
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
|
||||
});
|
||||
|
||||
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
|
||||
const { call, organization } = setup(t, KEPT);
|
||||
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
|
||||
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
|
||||
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
|
||||
assert.equal(organization.length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
|
||||
const { call, organization } = setup(t, KEPT);
|
||||
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
|
||||
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
|
||||
for (const op of ['add', 'change', 'delete'])
|
||||
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
|
||||
assert.equal(organization.length, 0);
|
||||
// The human's veto reaches the writer with the human as actor.
|
||||
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']);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
|
||||
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
|
||||
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. 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 HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
|
||||
const FIRE = { name: 'coder', reason: 'the work is done' };
|
||||
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
|
||||
const withoutReason = ({ reason, ...rest }) => rest;
|
||||
|
||||
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
|
||||
const { call, organization, allowed, store } = hireSetup(t);
|
||||
const result = call('chief', 'instance.hire', HIRE);
|
||||
assert.equal(result.transaction, 't-1');
|
||||
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
|
||||
const [use, ...rest] = allowed('role.hire');
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(use.subject, 'helper');
|
||||
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
|
||||
// Within-role: no decision is raised or needed.
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
// vars reach the writer with the name and definition.
|
||||
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
|
||||
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
|
||||
});
|
||||
|
||||
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
|
||||
// business's writer, and no argument names another.
|
||||
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const organization = [];
|
||||
const one = demo(LISTED).demo;
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: { demo: one, other: { ...one, id: 'other' } },
|
||||
roleFile: roleFile(ROLE_FILES),
|
||||
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
|
||||
});
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
for (const verb of ['instance.hire', 'instance.fire'])
|
||||
for (const extra of [{ business: 'other' }, { target: 'other' }])
|
||||
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
|
||||
assert.equal(organization.length, 0);
|
||||
b.request(chief, { verb: 'instance.hire', args: HIRE });
|
||||
b.request(chief, { verb: 'instance.fire', args: FIRE });
|
||||
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
|
||||
const { call, organization } = hireSetup(t);
|
||||
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
|
||||
const r = reason === undefined ? {} : { reason };
|
||||
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
|
||||
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
|
||||
assert.equal(organization.length, 0);
|
||||
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
|
||||
assert.equal(organization.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
|
||||
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
|
||||
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
|
||||
for (const who of ['pm', 'coder', 'assistant']) {
|
||||
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
|
||||
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
|
||||
}
|
||||
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
|
||||
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
|
||||
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
|
||||
for (const missing of ['role.hire', 'role.fire']) {
|
||||
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
|
||||
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
|
||||
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
|
||||
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
|
||||
assert.equal(organization.length, 0, missing);
|
||||
assert.equal(lastClaim('coder').op, 'claim', missing);
|
||||
// The other action still goes through.
|
||||
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
|
||||
call('chief', other, otherArgs);
|
||||
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
|
||||
}
|
||||
});
|
||||
|
||||
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', reason: 'needed again' };
|
||||
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), new RegExp(code));
|
||||
}
|
||||
writer.error = null;
|
||||
call('chief', 'instance.hire', again);
|
||||
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(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 run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
|
||||
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) => call('chief', 'instance.hire', { ...HIRE, definition });
|
||||
// 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);
|
||||
assert.throws(() => call('chief', 'instance.hire', HIRE), /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 logs', (t) => {
|
||||
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
|
||||
// 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: allowed('role.fire').length }; };
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
// 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: null, 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', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
|
||||
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] = allowed('role.fire');
|
||||
assert.equal(use.subject, 'coder');
|
||||
assert.equal(use.body.reason, 'the work is done');
|
||||
assert.equal(use.body.transaction, result.transaction);
|
||||
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
|
||||
// The human's read of the request shows the kill.
|
||||
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
|
||||
});
|
||||
|
||||
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
|
||||
// each becomes its own event against the instance. Only a fire's end carries them.
|
||||
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
|
||||
const { b, call, allowed } = hireSetup(t);
|
||||
call('chief', 'instance.fire', FIRE);
|
||||
const [end] = allowed('lifecycle.end');
|
||||
const request = end.body.request;
|
||||
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
|
||||
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
|
||||
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
|
||||
// sage-2289: only an alive stop carries a reason, and only unknown.
|
||||
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
|
||||
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
|
||||
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
|
||||
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
|
||||
assert.equal(closed.state, 'closed');
|
||||
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
|
||||
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
|
||||
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
|
||||
// A terminate's end sends SIGTERM only, so its done has no stops.
|
||||
const term = call('human', 'instance.end', { instance: 'pm' }).request;
|
||||
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
|
||||
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
|
||||
assert.equal(allowed('lifecycle.end.killed').length, 1);
|
||||
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
|
||||
});
|
||||
|
||||
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 = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
|
||||
assert.throws(() => fire('pm'), /instance-kept/);
|
||||
assert.throws(() => fire('nobody'), /unknown-role/);
|
||||
assert.throws(() => fire('assistant'), /organization-definition-refused/);
|
||||
assert.throws(() => fire('chief'), /organization-instance-referenced/);
|
||||
assert.throws(() => fire('coder'), /invalid-request/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + 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 logs once', (t) => {
|
||||
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
|
||||
writer.during = null;
|
||||
assert.equal(allowed('fire.stopped').length, 1);
|
||||
assert.equal(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 logs.
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
assert.deepEqual(result.runs, ['coder-run']);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
|
||||
// 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 fire unlogged and the instance firing, and the repeat logs it', (t) => {
|
||||
const { call, organization, allowed, store, writer } = hireSetup(t);
|
||||
// The delist commits, then the step C transaction aborts.
|
||||
writer.during = ([, verb]) => verb === 'instance.fire' && 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));
|
||||
writer.during = null;
|
||||
store.run('DROP TRIGGER fail_c');
|
||||
assert.equal(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);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.equal(allowed('fire.done').length, 1);
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
|
||||
const { call, store } = hireSetup(t);
|
||||
const step = (instance, action) =>
|
||||
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
|
||||
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
|
||||
step('coder', 'fire.done');
|
||||
step('coder', 'fire.stopped');
|
||||
step('pm', 'fire.done');
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
step('coder', 'fire.done');
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
|
||||
const { call, organization, lastClaim, allowed } = hireSetup(t);
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(lastClaim('pm').op, 'claim');
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
|
||||
// step A revokes, ends or marks anything, so the instance isn't left firing.
|
||||
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
|
||||
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
|
||||
writer.during = null;
|
||||
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
|
||||
assert.equal(lastClaim('coder').op, 'claim');
|
||||
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
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);
|
||||
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /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 carries the reason, 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 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 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'));
|
||||
|
||||
// The shipped chief file grants both actions, so the runtime takes the reason form.
|
||||
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
|
||||
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
|
||||
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, CONTEXT('more hands'));
|
||||
|
||||
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
|
||||
assert.equal(result.delisted, true);
|
||||
assert.ok(!document().launch.instances.includes('helper'));
|
||||
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
|
||||
// The reloaded business sees the delist: a refused call leaves the file alone.
|
||||
const after = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /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', 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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
|
||||
import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
|
||||
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
@@ -75,6 +75,8 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
assert.equal(view.definitions['merge-gate'].accepted, true);
|
||||
const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } });
|
||||
assert.equal(added.instances['sec-1'].launched, true);
|
||||
// Row 194 (D3 item 3): this boot copy's coder has no credential references and the file's has, so
|
||||
// the bus doesn't reload and asks for a restart. reload.test.mjs boots from the file.
|
||||
assert.equal(added.restart, true);
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
|
||||
const first = log().at(-1);
|
||||
@@ -84,7 +86,8 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const entries = log().length;
|
||||
const code = (args) => { try { bus.broker.request(human, { verb: 'instances.set', args: { revision: changed.revision, ...args } }); } catch (e) { return e.code; } return null; };
|
||||
assert.equal(code({ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }), 'organization-credentials-out-of-scope');
|
||||
// Row 194 (brief item 7, sage-1610 a): an add of a vikunja definition needs its bot.
|
||||
assert.equal(code({ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }), 'organization-tracker-required');
|
||||
assert.equal(code({ instance: 'sec-1', op: 'change', definition: 'coder' }), 'organization-credentials-out-of-scope');
|
||||
assert.equal(code({ instance: 'sec-1', op: 'add', definition: 'merge-gate' }), 'organization-instance-exists');
|
||||
assert.equal(code({ instance: 'ghost', op: 'change', vars: {} }), 'organization-unknown-instance');
|
||||
@@ -94,3 +97,42 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries, 'a refused write logs nothing');
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D4, D4.1): the human lifecycle verbs so far, veto.set and instances.set's three ops.
|
||||
test('row 194 T12: agent and reader sessions refuse veto.set; the tester gets add and change but not delete or veto.set; the human passes the hook for each listed verb', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([verb, args.op, actor]), verb === 'instances.check' ? { revision: ADD.revision } : { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
const VETO = { revision: ADD.revision, op: 'keep', name: 'pm' };
|
||||
for (const [who, cap] of Object.entries(caps)) {
|
||||
assert.throws(() => b.request(cap, { verb: 'veto.set', args: VETO }), /human-required/, who);
|
||||
assert.throws(() => b.request(cap, { verb: 'instances.set', args: { ...ADD, op: 'delete' } }), /human-required/, who);
|
||||
}
|
||||
assert.throws(() => b.request(tester, { verb: 'veto.set', args: VETO }), /tester-refused/);
|
||||
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }), /tester-refused/);
|
||||
assert.equal(TESTER_ORGANIZATION_VERBS.includes('veto.set'), false);
|
||||
assert.equal(calls.length, 0);
|
||||
// veto.set takes exactly its three keys, and instances.set only its three ops.
|
||||
for (const args of [{ ...VETO, actor: 'forged' }, { revision: VETO.revision, op: 'keep' }, { ...VETO, holder: 'mos' }])
|
||||
assert.throws(() => b.request(human, { verb: 'veto.set', args }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, op: 'remove' } }), /invalid-request/);
|
||||
assert.equal(calls.length, 0);
|
||||
// The hook refuses a verb missing from LIFECYCLE_HUMAN_VERBS, so each call below passing shows the
|
||||
// hook got that exact entry.
|
||||
assert.deepEqual([...LIFECYCLE_HUMAN_VERBS].sort(), ['instance.end', 'instance.launch', 'instance.regenerate', 'instances.set add', 'instances.set change', 'instances.set delete', 'veto.set']);
|
||||
b.request(human, { verb: 'veto.set', args: VETO });
|
||||
b.request(human, { verb: 'instances.set', args: ADD });
|
||||
b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
|
||||
// S2c: the delete is a request after the writer's check, and writes nothing yet.
|
||||
assert.ok(b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }).request);
|
||||
b.request(tester, { verb: 'instances.set', args: ADD });
|
||||
b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
|
||||
assert.deepEqual(calls, [['veto.set', 'keep', 'jason'], ['instances.set', 'add', 'jason'], ['instances.set', 'change', 'jason'],
|
||||
['instances.check', undefined, 'jason'], ['instances.set', 'add', 'tester'], ['instances.set', 'change', 'tester']]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
|
||||
// after the writer's checks and before the write. Fire revokes each service and keeps the store
|
||||
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
|
||||
// credential-revoke-failed and the business file's bytes unchanged. Fake tokens in a scratch root.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotateBy' : 'expires']: FAR_DATE });
|
||||
|
||||
// coder: gitea in the store, vikunja a file reference. rev-2 and rev-3: both in the store, with a
|
||||
// tracker bot. `drop` names instances whose credentials the boot copy leaves out, as a restart that
|
||||
// dropped the entry would: the running bus then has no entry for a file on disk (sage-1592).
|
||||
async function setup(t, { drop = [] } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-lifecycle-revoke-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.roles.coder.credentials.gitea = store('coder', 'gitea');
|
||||
for (const [name, botId] of [['rev-2', 40], ['rev-3', 41]]) {
|
||||
doc.roles[name] = { definition: 'reviewer', tracker: { bot: `bot-demo-${name}`, botId }, credentials: { gitea: store(name, 'gitea'), vikunja: store(name, 'vikunja') } };
|
||||
}
|
||||
doc.launch.instances.push('rev-2', 'rev-3');
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
const b = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
|
||||
const boot = busBusiness(b, resolved);
|
||||
for (const name of drop) delete boot.roles[name].credentials;
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: boot }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => {
|
||||
await bus.close();
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o700);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
|
||||
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
|
||||
const plant = (agent, service) => {
|
||||
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(agentDir(agent), service), TOKEN + '\n', { mode: 0o600 });
|
||||
};
|
||||
// A delete runs as the launcher's pass would: the verb's request, the take, then lifecycleDelete.
|
||||
// A refused delete closes the request with its code, as the launcher does.
|
||||
const remove = (instance) => {
|
||||
const { request } = ask('instances.set', { revision: ask('instances').revision, instance, op: 'delete' });
|
||||
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
try {
|
||||
return { request, ...bus.lifecycleDelete({ business: 'demo', request }) };
|
||||
} catch (e) {
|
||||
bus.lifecycleDone({ business: 'demo', request, refusal: e.code });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const bytes = () => readFileSync(file, 'utf8');
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
|
||||
return { root, bus, ask, fire, remove, agentDir, plant, bytes, code, status, file };
|
||||
}
|
||||
|
||||
test('T20 fire: a revoke that fails stops the fire with the file unchanged; one that succeeds revokes, keeps the directory and names the services outside the store', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: '2099-01-01' });
|
||||
assert.ok(existsSync(join(f.agentDir('coder'), 'gitea')));
|
||||
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
|
||||
const before = f.bytes();
|
||||
chmodSync(f.agentDir('coder'), 0o500);
|
||||
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
|
||||
chmodSync(f.agentDir('coder'), 0o700);
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
|
||||
|
||||
const fired = f.fire('coder');
|
||||
assert.equal(fired.delisted, true);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
|
||||
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
|
||||
assert.deepEqual(f.status('coder'), { gitea: 'revoked', vikunja: 'revoked' });
|
||||
// The directory stays, empty, so the status reads revoked and the human removes the remote token.
|
||||
assert.deepEqual(readdirSync(f.agentDir('coder')), []);
|
||||
});
|
||||
|
||||
// webby-0094 N1: a fire of an instance already delisted (here a hand edit) still revokes its tokens,
|
||||
// with no write, and the instance is no longer firing.
|
||||
test('T20 fire: an instance already delisted still has its tokens revoked and the file is left alone', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('credentials.issue', { agent: 'rev-2', service: 'gitea', token: TOKEN, date: '2099-01-01' });
|
||||
assert.ok(existsSync(join(f.agentDir('rev-2'), 'gitea')));
|
||||
const doc = JSON.parse(f.bytes());
|
||||
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const before = f.bytes();
|
||||
const fired = f.fire('rev-2');
|
||||
assert.equal(fired.delisted, false);
|
||||
assert.equal(fired.transaction, null);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.deepEqual(readdirSync(f.agentDir('rev-2')), []);
|
||||
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
|
||||
});
|
||||
|
||||
test('T20 fire: with no entry in the running bus, a token on disk refuses and an empty store goes on', async (t) => {
|
||||
const f = await setup(t, { drop: ['rev-2'] });
|
||||
f.plant('rev-2', 'gitea');
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
|
||||
assert.equal(f.bytes(), before);
|
||||
rmSync(join(f.agentDir('rev-2'), 'gitea'));
|
||||
const fired = f.fire('rev-2');
|
||||
assert.equal(fired.delisted, true);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
|
||||
});
|
||||
|
||||
test('T20 delete: a revoke that fails keeps the file; one that succeeds clears the store, deletes, and names the tracker bot', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' });
|
||||
// remove refuses a name it doesn't own (credential-file), so the delete stops with the file unchanged.
|
||||
writeFileSync(join(f.agentDir('rev-2'), 'notes'), 'x');
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.remove('rev-2')), 'credential-revoke-failed');
|
||||
assert.equal(f.bytes(), before);
|
||||
// remove revokes the services it knows before it reads the directory (row 199), so the token is
|
||||
// already gone: a failed delete leaves the agent listed with nothing it can use.
|
||||
assert.equal(existsSync(join(f.agentDir('rev-2'), 'vikunja')), false);
|
||||
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
|
||||
rmSync(join(f.agentDir('rev-2'), 'notes'));
|
||||
|
||||
const out = f.remove('rev-2');
|
||||
assert.equal(out.bot, 'bot-demo-rev-2');
|
||||
assert.equal(existsSync(f.agentDir('rev-2')), false);
|
||||
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
|
||||
const after = JSON.parse(f.bytes());
|
||||
assert.equal(Object.hasOwn(after.roles, 'rev-2'), false);
|
||||
assert.ok(after.retired.includes('rev-2'));
|
||||
// The launcher's close carries the bot, so the CLI and the Console name it.
|
||||
const done = f.bus.lifecycleDone({ business: 'demo', request: out.request, transaction: out.transaction, bot: out.bot });
|
||||
assert.equal(done.done.bot, 'bot-demo-rev-2');
|
||||
});
|
||||
|
||||
test('T20 delete: with no entry in the running bus, a token file is cleared and the delete goes on; a file reference refuses', async (t) => {
|
||||
const f = await setup(t, { drop: ['rev-3'] });
|
||||
f.plant('rev-3', 'gitea');
|
||||
const out = f.remove('rev-3');
|
||||
assert.equal(out.bot, 'bot-demo-rev-3');
|
||||
assert.equal(existsSync(f.agentDir('rev-3')), false);
|
||||
assert.equal(Object.hasOwn(JSON.parse(f.bytes()).roles, 'rev-3'), false);
|
||||
// coder's vikunja is a file reference: deleting it stays a hand edit, refused at the request.
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.remove('coder')), 'organization-credentials-out-of-scope');
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.deepEqual(f.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
});
|
||||
|
||||
test('T21 delete: a write between the raise and the take makes the delete refuse revision-conflict before it revokes anything', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' });
|
||||
const { request } = f.ask('instances.set', { revision: f.ask('instances').revision, instance: 'rev-2', op: 'delete' });
|
||||
assert.equal(f.bus.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
// A write after the raise: the request's revision is now stale.
|
||||
const raised = f.bytes();
|
||||
f.ask('credentials.issue', { agent: 'rev-3', service: 'gitea', token: 'fake_other_token_9876543210', date: '2099-02-01' });
|
||||
assert.ok(f.bytes() !== raised, 'the issue wrote the business file');
|
||||
const before = f.bytes();
|
||||
const entry = structuredClone(JSON.parse(before).roles['rev-2'].credentials);
|
||||
assert.equal(f.code(() => f.bus.lifecycleDelete({ business: 'demo', request })), 'revision-conflict');
|
||||
// Nothing revoked: the token stays issued, Credentials is unchanged and no store file is removed.
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.deepEqual(JSON.parse(f.bytes()).roles['rev-2'].credentials, entry);
|
||||
assert.equal(readFileSync(join(f.agentDir('rev-2'), 'vikunja'), 'utf8'), TOKEN + '\n');
|
||||
assert.deepEqual(readdirSync(f.agentDir('rev-2')), ['vikunja']);
|
||||
assert.deepEqual(f.status('rev-2'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
// Row 194 (#1692, D1.1, V2-V5, D4.1): the broker half of the lifecycle verbs. Launch, regenerate
|
||||
// and delete are requests the launcher's pass takes through the host ops. Terminate revokes and
|
||||
// cancels at the verb and names the runs the pass signals. The launcher half is in cli lifecycle.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const R = 'a'.repeat(64);
|
||||
const doc = (extra = {}) => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
},
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const tester = { on: true };
|
||||
const b = new Broker({ store, businesses: { demo: doc(), other: OTHER }, organization,
|
||||
testerOn: testerOn === undefined ? () => tester.on : testerOn, ...(processing ? { processing } : {}) });
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
const ask = (who, verb, args = {}) => code(() => call(who, verb, args));
|
||||
let runs = 0;
|
||||
const bind = (role, { claim = true, thread, name = `${role}-${++runs}` } = {}) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) });
|
||||
if (claim) call(name, 'role.claim');
|
||||
return name;
|
||||
};
|
||||
const lifecycle = () => store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') LIKE 'lifecycle.%'").n;
|
||||
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
|
||||
const take = (request) => b.lifecycleTake({ business: 'demo', request });
|
||||
const done = (request, extra = {}) => b.lifecycleDone({ business: 'demo', request, ...extra });
|
||||
const state = (request) => call('human', 'instance.request', { request });
|
||||
return { b, store, caps, call, code, ask, bind, lifecycle, inputs, take, done, state, tester };
|
||||
}
|
||||
// Moves the clock past a request's 60 s, for the rest of the test.
|
||||
const later = (t) => { const now = Date.now(); t.mock.method(Date, 'now', () => now + LIFECYCLE_REQUEST_MS + 1000); };
|
||||
|
||||
test('row 194 T12: agent and reader sessions refuse the lifecycle verbs before any effect; the tester gets launch, end and the request read, never regenerate', (t) => {
|
||||
const f = setup(t);
|
||||
f.bind('chief');
|
||||
f.bind('pm');
|
||||
const verbs = [['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
|
||||
['instance.end', { instance: 'coder' }], ['instance.request', { request: 'r1' }]];
|
||||
for (const [verb, args] of verbs) {
|
||||
assert.equal(f.ask('reader', verb, args), 'read-only', `reader ${verb}`);
|
||||
for (const who of ['chief-1', 'pm-2']) assert.equal(f.ask(who, verb, args), 'human-required', `${who} ${verb}`);
|
||||
}
|
||||
assert.equal(f.ask('tester', 'instance.regenerate', { instance: 'coder' }), 'tester-refused');
|
||||
assert.equal(f.lifecycle(), 0);
|
||||
for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
|
||||
assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
|
||||
// Each human verb passes the D4 hook with its LIFECYCLE_HUMAN_VERBS entry.
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
f.call('human', 'instance.regenerate', { instance: 'pm' });
|
||||
assert.deepEqual(f.call('human', 'instance.end', { instance: 'coder' }).cancelled, [request]);
|
||||
});
|
||||
|
||||
test('row 194 V2 and V4 at the verb: the request is open, by the human, with a human.input; thread, retired, unknown and revoked refuse with no event', (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.inputs();
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
const r = f.state(request);
|
||||
assert.deepEqual([r.action, r.instance, r.by, r.state, r.done, r.cancelled], ['lifecycle.launch', 'coder', 'jason', 'open', null, null]);
|
||||
assert.equal(f.inputs(), before + 1);
|
||||
const raw = JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body);
|
||||
assert.deepEqual(raw, { action: 'lifecycle.launch', request, via: 'cli', by: 'jason' });
|
||||
const count = f.lifecycle();
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'assistant' }), 'not-thread-launch');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'assistant' }), 'regenerate-thread');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'old' }), 'instance-retired');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'old' }), 'instance-retired');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'ghost' }), 'unknown-role');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder', extra: 1 }), 'invalid-request');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: '../x' }), 'invalid-request');
|
||||
f.call('human', 'launch.revoke');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'launch-revoked');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'launch-revoked');
|
||||
assert.equal(f.lifecycle(), count);
|
||||
assert.equal(f.ask('human', 'instance.request', { request: 'no-such' }), 'unknown-request');
|
||||
});
|
||||
|
||||
test('row 194 D1.1 take: a request is taken once, closes once, and lifecycleRequests lists only what is not closed', (t) => {
|
||||
const f = setup(t);
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
const second = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => [r.request, r.state]), [[request, 'open'], [second, 'open']]);
|
||||
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-not-taken');
|
||||
const go = f.take(request);
|
||||
assert.deepEqual([go.go, go.refusal, go.state, go.action, go.instance, go.by], [true, null, 'taken', 'lifecycle.launch', 'coder', 'jason']);
|
||||
const again = f.take(request);
|
||||
assert.deepEqual([again.go, again.refusal, again.state], [false, 'request-closed', 'taken']);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 1);
|
||||
assert.equal(f.code(() => f.done(request, { refusal: 'Not A Code' })), 'invalid-request');
|
||||
assert.equal(f.code(() => f.done(request, { run: '../x' })), 'invalid-request');
|
||||
assert.equal(f.code(() => f.done(request, { extra: 1 })), 'invalid-request');
|
||||
const closed = f.done(request, { run: 'run-x' });
|
||||
assert.deepEqual([closed.state, closed.done], ['closed', { run: 'run-x', transaction: null, refusal: null }]);
|
||||
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-closed');
|
||||
assert.deepEqual(f.take(request).refusal, 'request-closed');
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => r.request), [second]);
|
||||
assert.equal(f.code(() => f.take('no-such')), 'unknown-request');
|
||||
assert.equal(f.code(() => f.b.lifecycleRequests('ghost')), 'unknown-business');
|
||||
// A request from one business isn't another's.
|
||||
assert.equal(f.code(() => f.b.lifecycleTake({ business: 'other', request: second })), 'unknown-request');
|
||||
});
|
||||
|
||||
test('row 194 D1.1 take: a launch.revoke after the request closes it launch-revoked, and a request past 60 s closes request-expired', (t) => {
|
||||
const f = setup(t);
|
||||
const revoked = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const regen = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
|
||||
f.call('human', 'launch.revoke');
|
||||
for (const request of [revoked, regen]) {
|
||||
const r = f.take(request);
|
||||
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'launch-revoked', 'closed', 'launch-revoked']);
|
||||
}
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 0);
|
||||
f.call('human', 'launch.restore');
|
||||
const old = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
later(t);
|
||||
const r = f.take(old);
|
||||
assert.deepEqual([r.go, r.refusal, r.state], [false, 'request-expired', 'closed']);
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo'), []);
|
||||
});
|
||||
|
||||
// T15 (D6): row 191's switch, read through the broker's processing reader as the bus reads its file.
|
||||
test('row 194 T15 (bus): while halted, launch and regenerate refuse processing-halted at the verb and at the take, hire and fire refuse, terminate goes; an unreadable switch refuses with its code', (t) => {
|
||||
const generation = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
|
||||
const sw = { state: { version: 1, halted: false, generation } };
|
||||
const f = setup(t, { processing: { read: () => sw.state } });
|
||||
const chief = f.bind('chief');
|
||||
const coder = f.bind('coder');
|
||||
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const regen = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||
const testerLaunch = f.call('tester', 'instance.launch', { instance: 'pm' }).request;
|
||||
sw.state = { version: 1, halted: true, generation };
|
||||
const count = f.lifecycle();
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'processing-halted');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'processing-halted');
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'processing-halted');
|
||||
assert.equal(f.lifecycle(), count, 'a refused verb records no request');
|
||||
for (const request of [launch, regen, testerLaunch]) {
|
||||
const r = f.take(request);
|
||||
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'processing-halted', 'closed', 'processing-halted'], request);
|
||||
}
|
||||
// Row 191's work table refuses the chief's hire and fire before anything runs.
|
||||
assert.equal(f.ask(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'more hands' }), 'processing-halted');
|
||||
assert.equal(f.ask(chief, 'instance.fire', { name: 'coder', reason: 'done' }), 'processing-halted');
|
||||
// Terminate is allowed while halted: it revokes, and its end goes at the take.
|
||||
const end = f.call('human', 'instance.end', { instance: 'coder' });
|
||||
assert.deepEqual(end.runs, [coder]);
|
||||
assert.equal(f.take(end.request).go, true);
|
||||
sw.state = { halted: true, invalid: true, code: 'processing-control-invalid' };
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'pm' }), 'processing-control-invalid');
|
||||
sw.state = { version: 1, halted: false, generation };
|
||||
const after = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
assert.equal(f.take(after).go, true);
|
||||
});
|
||||
|
||||
test('row 194 T12a: the tester launches and ends on its own business with actor tester; bus.tester off in the file refuses the call and the take tester-off', (t) => {
|
||||
const f = setup(t);
|
||||
const inputs = f.inputs();
|
||||
const { request } = f.call('tester', 'instance.launch', { instance: 'coder' });
|
||||
const r = f.call('tester', 'instance.request', { request });
|
||||
assert.deepEqual([r.by, r.state], ['tester', 'open']);
|
||||
assert.equal(JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body).via, 'tester');
|
||||
assert.equal(f.inputs(), inputs, 'a tester request is not human input');
|
||||
const human = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
// A tester reads only its own requests.
|
||||
assert.equal(f.ask('tester', 'instance.request', { request: human }), 'unknown-request');
|
||||
// The cap is bound to demo: an instance only `other` declares is unknown here.
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'only-there' }), 'unknown-role');
|
||||
// bus.tester switched off in the file, with no restart.
|
||||
f.tester.on = false;
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
|
||||
assert.equal(f.ask('tester', 'instance.end', { instance: 'coder' }), 'tester-off');
|
||||
const off = f.take(request);
|
||||
assert.deepEqual([off.go, off.refusal], [false, 'tester-off']);
|
||||
assert.equal(f.take(human).go, true, 'the human request is not the tester switch');
|
||||
// Anything but exactly true is off.
|
||||
f.tester.on = 'true';
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
|
||||
f.tester.on = true;
|
||||
const later2 = f.call('tester', 'instance.launch', { instance: 'coder' }).request;
|
||||
const ended = f.call('tester', 'instance.end', { instance: 'coder' });
|
||||
assert.deepEqual(ended.cancelled, [later2]);
|
||||
assert.equal(f.state(ended.request).by, 'tester');
|
||||
assert.equal(f.state(later2).cancelled.by, 'tester');
|
||||
});
|
||||
|
||||
test('row 194 V3 terminate: claims revoked, open requests cancelled, bound runs named, taken requests listed; nothing at all answers not-held', (t) => {
|
||||
const f = setup(t);
|
||||
const held = f.bind('coder');
|
||||
const unclaimed = f.bind('coder', { claim: false });
|
||||
const open = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const taken = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
|
||||
f.take(taken);
|
||||
const other = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
const out = f.call('human', 'instance.end', { instance: 'coder', reason: 'stuck on a loop' });
|
||||
assert.deepEqual([out.runs, out.cancelled, out.taken], [[held, unclaimed], [open], [taken]]);
|
||||
const claim = f.store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
|
||||
assert.deepEqual([claim.op, claim.holder_run, claim.by, claim.reason], ['revoke', held, 'jason', 'stuck on a loop']);
|
||||
assert.deepEqual([f.state(open).state, f.state(open).cancelled], ['closed', { by: 'jason' }]);
|
||||
assert.equal(f.state(taken).state, 'taken', 'a taken request is past cancelling');
|
||||
assert.equal(f.state(other).state, 'open', 'another instance keeps its request');
|
||||
// The end is a request the pass takes, with the runs to signal. It never expires.
|
||||
const end = f.state(out.request);
|
||||
assert.deepEqual([end.action, end.runs, end.state], ['lifecycle.end', [held, unclaimed], 'open']);
|
||||
later(t);
|
||||
assert.equal(f.take(out.request).go, true);
|
||||
assert.equal(f.take(open).refusal, 'request-closed');
|
||||
// The runs stay named while bound and not ended.
|
||||
f.done(taken, { run: 'coder-new' });
|
||||
for (const run of [held, unclaimed]) f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'coder' }), 'not-held');
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'old' }), 'unknown-role');
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'coder', reason: '' }), 'invalid-request');
|
||||
});
|
||||
|
||||
test('row 194 V3 terminate of a thread instance: the human ends every thread, the tester only its own', (t) => {
|
||||
const f = setup(t);
|
||||
const hp = f.call('human', 'project.create', { name: 'human work' }).id;
|
||||
const tp = f.call('tester', 'project.create', { name: 'test work' }).id;
|
||||
const H = f.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
|
||||
const T = f.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
|
||||
const h = f.bind('assistant', { thread: H });
|
||||
const tr = f.bind('assistant', { thread: T });
|
||||
const tester = f.call('tester', 'instance.end', { instance: 'assistant' });
|
||||
assert.deepEqual(tester.runs, [tr]);
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'claim');
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'revoke');
|
||||
const human = f.call('human', 'instance.end', { instance: 'assistant' });
|
||||
assert.deepEqual(human.runs, [h, tr]);
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'revoke');
|
||||
});
|
||||
|
||||
// The writer stand-in: instances.check answers per instance, instances.set commits a transaction.
|
||||
function writer(answers) {
|
||||
const calls = [];
|
||||
const organization = (id, verb, args, actor) => {
|
||||
calls.push([verb, args, actor]);
|
||||
if (verb === 'instances.check') {
|
||||
const a = answers[args.instance] ?? { revision: R };
|
||||
if (a instanceof Error) throw a;
|
||||
return a;
|
||||
}
|
||||
if (verb === 'instances.set') return { transaction: `tx-${calls.length}`, restart: false };
|
||||
return { ok: true };
|
||||
};
|
||||
return { calls, organization };
|
||||
}
|
||||
const refused = (code) => Object.assign(new Error(code), { code });
|
||||
const lastListed = () => Object.assign(new Error('coder is the last name in launch.instances'), { name: 'BusinessError' });
|
||||
|
||||
test('row 194 T6 V5 order at the verb: the writer\'s named refusals, then instance-running, instance-starting, the last-listed refusal and the revision', (t) => {
|
||||
const w = writer({ chief: refused('organization-instance-referenced'), pm: lastListed(), coder: lastListed() });
|
||||
const f = setup(t, { organization: w.organization });
|
||||
const del = (instance, revision = R) => f.ask('human', 'instances.set', { revision, instance, op: 'delete' });
|
||||
// A named refusal comes before the broker's checks, even with a claim held.
|
||||
f.bind('chief');
|
||||
assert.equal(del('chief'), 'organization-instance-referenced');
|
||||
// The last-listed refusal waits until running and starting have passed.
|
||||
const run = f.bind('coder');
|
||||
assert.equal(del('coder'), 'instance-running');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
const starting = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
assert.equal(del('coder'), 'instance-starting');
|
||||
f.take(starting);
|
||||
assert.equal(del('coder'), 'instance-starting', 'a taken request with no done is still starting');
|
||||
f.done(starting, { refusal: 'launch-refused' });
|
||||
assert.equal(del('coder'), 'invalid-request');
|
||||
assert.equal(del('pm'), 'invalid-request');
|
||||
assert.equal(del('assistant', 'b'.repeat(64)), 'revision-conflict');
|
||||
assert.equal(del('assistant', 'not-a-revision'), 'invalid-request');
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete', definition: 'x' }), 'invalid-request');
|
||||
assert.ok(w.calls.every(([verb]) => verb === 'instances.check'), 'the checks write nothing');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.delete'").n, 0);
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete' });
|
||||
const r = f.state(request);
|
||||
assert.deepEqual([r.action, r.instance, r.by, r.revision, r.retry, r.state], ['lifecycle.delete', 'assistant', 'jason', R, false, 'open']);
|
||||
assert.deepEqual(w.calls.at(-1), ['instances.check', { instance: 'assistant' }, 'jason']);
|
||||
// An open delete is a starting request too: a second delete waits for the first.
|
||||
assert.equal(del('assistant'), 'instance-starting');
|
||||
});
|
||||
|
||||
test('row 194 V5: an open launch past its 60 s is not starting, as it can only close request-expired', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
later(t);
|
||||
assert.ok(f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' }).request);
|
||||
});
|
||||
|
||||
test('row 194 V5 after the take: lifecycleDelete re-checks running and starting, then commits through the writer with the human as actor', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' });
|
||||
const del = () => f.b.lifecycleDelete({ business: 'demo', request });
|
||||
assert.equal(f.code(del), 'request-not-taken');
|
||||
assert.equal(f.take(request).go, true);
|
||||
// A launch recorded after the delete's verb and a claim taken since both stop it.
|
||||
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
assert.equal(f.code(del), 'instance-starting');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
const run = f.bind('coder');
|
||||
assert.equal(f.code(del), 'instance-running');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
assert.equal(f.state(launch).cancelled.by, 'jason');
|
||||
const out = del();
|
||||
assert.deepEqual(w.calls.at(-1), ['instances.set', { revision: R, instance: 'coder', op: 'delete' }, 'jason']);
|
||||
assert.deepEqual(out, { transaction: out.transaction, retry: false });
|
||||
assert.match(out.transaction, /^tx-/);
|
||||
assert.equal(f.done(request, { transaction: out.transaction }).done.transaction, out.transaction);
|
||||
const launchReq = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
assert.equal(f.code(() => f.b.lifecycleDelete({ business: 'demo', request: launchReq })), 'request-not-taken');
|
||||
});
|
||||
|
||||
test('row 194 V5 retry: a retired name deletes again only after workspace-rename-failed, and that request commits nothing', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'old', op: 'delete' }), 'organization-instance-retired');
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' });
|
||||
f.take(request);
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
const retry = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }).request;
|
||||
assert.deepEqual([f.state(retry).retry, f.state(retry).revision], [true, R]);
|
||||
f.take(retry);
|
||||
// S3: the retry answers the failed delete's transaction, which names the rename's target.
|
||||
assert.ok(transaction);
|
||||
assert.deepEqual(f.b.lifecycleDelete({ business: 'demo', request: retry }), { transaction, retry: true });
|
||||
assert.equal(w.calls.length, writes, 'a retry neither checks nor writes the file');
|
||||
f.done(retry);
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }), 'organization-instance-retired');
|
||||
});
|
||||
|
||||
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { busBusiness } = await import('../src/business.mjs');
|
||||
const { loadBusiness } = await import('../../business/src/business.mjs');
|
||||
const { resolveInstance } = await import('../../business/src/resolve.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const b = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
const revision = () => ask('instances').revision;
|
||||
ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
const before = readFileSync(file, 'utf8');
|
||||
// The real writer's refusals reach the verb, and none of them writes.
|
||||
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'coder', op: 'delete' })), 'organization-credentials-out-of-scope');
|
||||
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'pm', op: 'delete' })), 'organization-instance-referenced');
|
||||
assert.equal(code(() => ask('instances.set', { revision: 'b'.repeat(64), instance: 'sec-1', op: 'delete' })), 'revision-conflict');
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
const { request } = ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'delete' });
|
||||
assert.equal(readFileSync(file, 'utf8'), before, 'the verb only records the request');
|
||||
assert.deepEqual(bus.lifecycleRequests('demo').map((r) => [r.request, r.action, r.instance]), [[request, 'lifecycle.delete', 'sec-1']]);
|
||||
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
|
||||
const after = JSON.parse(readFileSync(file, 'utf8'));
|
||||
assert.equal(Object.hasOwn(after.roles, 'sec-1'), false);
|
||||
assert.deepEqual(after.retired, ['sec-1']);
|
||||
assert.equal(after.launch.instances.includes('sec-1'), false);
|
||||
const log = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
assert.deepEqual([log.actor, log.state, log.transaction], ['jason', 'committed', transaction]);
|
||||
assert.equal(bus.lifecycleDone({ business: 'demo', request, transaction }).state, 'closed');
|
||||
assert.equal(code(() => bus.broker.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })), 'unknown-role');
|
||||
assert.equal(code(() => ask('instance.launch', { instance: 'sec-1' })), 'instance-retired');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return { r, client: new Client({ path: r.path, cap, timeout: 15000 }) };
|
||||
}
|
||||
|
||||
test('real runtime injects the coordinator, stops its workload and mirrors stable stop events across restart', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-runtime-bus-'));
|
||||
let live;
|
||||
const p = spawn(process.execPath, ['-e', "console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const exit = once(p, 'exit');
|
||||
t.after(async () => {
|
||||
await live?.close();
|
||||
if (p.exitCode === null && p.signalCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await exit; }
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
await once(p.stdout, 'data');
|
||||
let current = await broker(root); live = current.r;
|
||||
const receipt = registerRuntime(root, { kind: 'session', business: 'demo', run: 'runtime-fixture' });
|
||||
updateRuntime(root, receipt.id, { ...processIdentity(p.pid), phase: 'running', group: true });
|
||||
const halted = await current.client.call('processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
assert.equal(halted.quiescent, true);
|
||||
await exit;
|
||||
assert.equal(processIdentity(p.pid), null);
|
||||
await assert.rejects(live.mayLaunch({ business: 'demo', instance: 'coder', provider: 'fixture', model: 'fixture' }), { code: 'processing-halted' });
|
||||
await live.close(); live = null;
|
||||
current = await broker(root); live = current.r;
|
||||
assert.equal((await current.client.call('processing.status')).halted, true);
|
||||
const events = [halted.generation, receipt.id].flatMap(subject =>
|
||||
live.broker.request(live.readers[0].cap, { verb: 'trail', args: { subject } }));
|
||||
assert.equal(events.filter(e => e.body?.action === 'processing.halt').length, 1);
|
||||
assert.equal(events.filter(e => e.body?.action === 'processing.stop').length, 1);
|
||||
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
});
|
||||
|
||||
test('invalid state still starts the control bus and human halt then resume repairs it', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-repair-bus-'));
|
||||
let live;
|
||||
t.after(async () => { await live?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
let current = await broker(root); live = current.r;
|
||||
await live.close(); live = null;
|
||||
writeFileSync(join(root, 'control', 'processing.json'), '{');
|
||||
current = await broker(root); live = current.r;
|
||||
assert.equal((await current.client.call('processing.status')).code, 'processing-state-invalid');
|
||||
await assert.rejects(current.client.call('processing.resume', { generation: 'bad' }), { code: 'processing-state-invalid' });
|
||||
const halted = await current.client.call('processing.halt');
|
||||
assert.equal(halted.recovered, true);
|
||||
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
assert.equal(readControl(root).halted, false);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
test('processing halt and resume extend only the socket timeout while ordinary status retains the explicit timeout', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-client-')), path = join(root, 'fixture.sock');
|
||||
const seen = [], sockets = new Set();
|
||||
const server = createServer(socket => {
|
||||
sockets.add(socket); let buffer = '', timer;
|
||||
socket.on('error', () => {});
|
||||
socket.on('close', () => { sockets.delete(socket); clearTimeout(timer); });
|
||||
socket.on('data', data => {
|
||||
buffer += data;
|
||||
if (!buffer.includes('\n')) return;
|
||||
seen.push(JSON.parse(buffer));
|
||||
timer = setTimeout(() => socket.end(JSON.stringify({ ok: true, result: { real: 'completion' } }) + '\n'), 80);
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => server.listen(path, resolve));
|
||||
t.after(async () => { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); rmSync(root, { recursive: true, force: true }); });
|
||||
const client = new Client({ path, cap: 'a'.repeat(64), timeout: 15 });
|
||||
for (const verb of ['processing.halt', 'processing.resume']) {
|
||||
assert.deepEqual(await client.call(verb, {}), { real: 'completion' });
|
||||
assert.equal(client.timeout, 15, 'the constructor setting stays unchanged');
|
||||
}
|
||||
await assert.rejects(client.call('processing.status'), { code: 'outcome-unknown' });
|
||||
assert.deepEqual(seen, ['processing.halt', 'processing.resume', 'processing.status'].map(verb => ({ cap: 'a'.repeat(64), verb, args: {} })));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initializeControl, transition } from '../../processing-control/src/state.mjs';
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
import { webService } from '../src/web.mjs';
|
||||
test('a root halt aborts the active search-source signal before the normal web deadline', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-web-')); initializeControl(root);
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
let ready, observed;
|
||||
const started = new Promise(resolve => { ready = resolve; });
|
||||
const service = webService({ deadlineMs: 3000, search: { search: (_query, { signal }) => new Promise((_resolve, reject) => {
|
||||
observed = signal; signal.addEventListener('abort', () => reject(signal.reason), { once: true }); ready();
|
||||
}) } });
|
||||
const call = withProcessing(root, () => service.handle('web.search', { query: 'fixture' }));
|
||||
await started; const began = Date.now(); transition(root, { business: 'demo', human: 'jason', halted: true });
|
||||
await call.catch(() => {});
|
||||
assert.equal(observed.aborted, true); assert.equal(observed.reason.name, 'ProcessingError');
|
||||
assert.ok(Date.now() - began < 1500);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
const state = await import('../../processing-control/src/state.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
assert.equal(typeof state.initializeControl, 'function');
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-bus-'));
|
||||
state.initializeControl(root);
|
||||
const store = new Store(root);
|
||||
const processing = {
|
||||
read: () => state.readControl(root),
|
||||
async halt(actor) { return state.transition(root, { ...actor, halted: true }); },
|
||||
async resume(actor) { return state.transition(root, { ...actor, halted: false }); },
|
||||
};
|
||||
const b = new Broker({ store, businesses, processing, ...extra });
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'coder', run: 'run', harness: 'pi' });
|
||||
b.request(cap, { verb: 'role.claim' });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, b, store, cap, human, reader, tester, processing,
|
||||
call: (who, verb, args = {}) => b.request(who, { verb, args }) };
|
||||
}
|
||||
|
||||
// Dropping #human before either effect would allow a role/tester to clear the switch.
|
||||
for (const verb of ['processing.halt', 'processing.resume']) test(`${verb} refuses agent reader and tester before any transition`, async t => {
|
||||
const f = fixture(t), before = state.readControl(f.root);
|
||||
for (const cap of [f.cap, f.reader, f.tester]) {
|
||||
await assert.rejects(async () => f.call(cap, verb, { generation: before.generation }), e => ['human-required', 'read-only', 'tester-refused'].includes(e.code));
|
||||
}
|
||||
assert.deepEqual(state.readControl(f.root), before);
|
||||
});
|
||||
|
||||
test('human halt and clear each have one event with verified actor; launch.restore cannot clear', async t => {
|
||||
const f = fixture(t);
|
||||
const halted = await f.call(f.human, 'processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
await f.call(f.human, 'processing.halt');
|
||||
f.call(f.human, 'launch.restore');
|
||||
assert.equal(f.call(f.reader, 'processing.status').halted, true);
|
||||
const resumed = await f.call(f.human, 'processing.resume', { generation: halted.generation });
|
||||
assert.equal(resumed.halted, false);
|
||||
const events = f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') IN ('processing.halt','processing.resume')").map(r => JSON.parse(r.body));
|
||||
assert.deepEqual(events.map(e => [e.action, e.human]), [['processing.halt', 'jason'], ['processing.resume', 'jason']]);
|
||||
assert.equal(new Set(events.map(e => e.operation)).size, 2);
|
||||
});
|
||||
|
||||
test('all ordinary work verbs refuse before dispatch while pure reads remain available', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
for (const verb of ['role.claim', 'role.revoke', 'decision.raise', 'decision.resolve', 'message.send', 'message.receive', 'message.read', 'staff.request', 'review.request', 'review.verdict', 'event.emit', 'organization.set', 'record.write', 'usage.begin', 'unknown.future.work']) {
|
||||
await assert.rejects(async () => f.call(f.cap, verb), { code: 'processing-halted' }, verb);
|
||||
}
|
||||
assert.equal(f.call(f.cap, 'state').launch.state, 'allowed');
|
||||
assert.ok(Array.isArray(f.call(f.reader, 'agents')));
|
||||
assert.equal(f.call(f.reader, 'processing.status').halted, true);
|
||||
// A human can still queue intent and manage settings under their existing permissions.
|
||||
assert.doesNotThrow(() => f.call(f.human, 'message.send', { to: 'coder', body: 'wait for resume' }));
|
||||
assert.doesNotThrow(() => f.call(f.cap, 'role.release'));
|
||||
});
|
||||
|
||||
test('task web authorization and bind paths refuse before external work', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
let effects = 0;
|
||||
await assert.rejects(f.b.requestTask(f.cap, { verb: 'task.get', args: {} }, () => { effects++; }), { code: 'processing-halted' });
|
||||
await assert.rejects(f.b.requestWeb(f.cap, { verb: 'web.fetch', args: { url: 'https://example.com' } }, { handle() { effects++; } }), { code: 'processing-halted' });
|
||||
assert.throws(() => f.b.authorize(f.cap, 'role.launch', { target: 'coder' }), { code: 'processing-halted' });
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'coder', run: 'later', harness: 'pi' }), { code: 'processing-halted' });
|
||||
assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
for (const action of ['processing.halt', 'processing.resume', 'processing.stop']) test(`recordEvent cannot forge ${action}`, t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.b.recordEvent(f.cap, { kind: 'action.allowed', body: { action, human: 'jason' } }), { code: 'reserved-event' });
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n, 0);
|
||||
});
|
||||
|
||||
for (const own of [false, true]) test(`recordTask ${own ? 'self' : 'poll'} cannot forge a processing action through its kinds filter`, t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.b.recordTask({ business: 'demo', ...(own ? { cap: f.cap } : {}), events: [
|
||||
{ kind: 'action.allowed', body: { action: 'processing.halt', human: 'jason' } },
|
||||
] }), { code: 'reserved-event' });
|
||||
});
|
||||
|
||||
test('spec read remains a read but cannot release new write work after an await', async t => {
|
||||
let finish;
|
||||
const ready = new Promise(resolve => { finish = resolve; });
|
||||
const f = fixture(t, { project: { async writeRecord() { await ready; return { path: 'docs/a.md', commit: 'a'.repeat(40) }; } } });
|
||||
const writing = f.call(f.cap, 'record.write', { project: 'stack', path: 'docs/a.md', record: '# A' });
|
||||
await f.call(f.human, 'processing.halt'); finish();
|
||||
// An already committed external effect must remain outcome-unknown, not a fake rollback.
|
||||
await assert.rejects(writing, { code: 'outcome-unknown' });
|
||||
});
|
||||
|
||||
test('the real socket awaits halt and resume beyond the ordinary idle deadline', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'control.sock');
|
||||
const halt = f.processing.halt;
|
||||
f.processing.halt = async args => { await new Promise(r => setTimeout(r, 30)); return halt(args); };
|
||||
let peers = 0;
|
||||
const server = await serve({ broker: f.b, path, timeout: 5,
|
||||
// Row 196 added asynchronous kernel-peer discovery before the dispatch timeout.
|
||||
// This test isolates the post-auth await; peer proof is exercised in its owning files.
|
||||
peerReader: { close: async () => {}, read: async () => { peers++; return { pid: process.pid, uid: process.getuid(), gid: process.getgid() }; } },
|
||||
authenticateHuman: () => f.b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) });
|
||||
try {
|
||||
const client = new Client({ path, human: { fixture: true }, timeout: 1000 });
|
||||
const halted = await client.call('processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
assert.equal((await client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
assert.equal((await new Client({ path, cap: f.reader }).call('processing.status')).halted, false);
|
||||
assert.equal(peers, 2);
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('a malformed processing reader never silently admits agent work', t => {
|
||||
const f = fixture(t);
|
||||
for (const answer of [null, {}, { halted: false }, { version: 1, generation: 'bad', halted: false }, { halted: 'false' }]) {
|
||||
f.processing.read = () => answer;
|
||||
assert.throws(() => f.call(f.cap, 'message.send', { to: 'human', body: 'no' }), { code: 'processing-state-invalid' });
|
||||
}
|
||||
});
|
||||
|
||||
test('halt refuses the newly landed host executor path before credentials or effects', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
let called = false;
|
||||
await assert.rejects(f.b.requestHost(f.cap, { verb: 'host.run', args: {} }, {
|
||||
busy: () => { called = true; return false; }, run: async () => { called = true; },
|
||||
}), e => e.code === 'processing-halted');
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -15,7 +15,9 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||
"import { isAbsolute } from 'node:path';",
|
||||
"import { isAbsolute, dirname } from 'node:path';",
|
||||
// Row 191 / sage-1367: the one additional read-only CODE source, with no project/git imports.
|
||||
"import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';",
|
||||
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
|
||||
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
@@ -26,8 +28,9 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
|
||||
// Row 210 (#1707) reads credentialKind and the D6 argv check, validArgv and commandAllowed, from it.
|
||||
// Row 234 (#1740) reads HOST_ID for the hosts verbs.
|
||||
// Row 213 (#1714) reads CREATIVE_DEFINITIONS for the creative raise refusal (M6).
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';",
|
||||
// Row 213 (#1714) reads CREATIVE_DEFINITIONS for the creative raise refusal (M6). Row 194
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
|
||||
// restart would load it, so a created instance binds with no restart. A definition change on a
|
||||
// running instance refuses, and a file whose credential references changed isn't reloaded.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||
|
||||
// The bus form of the business file, built as cli bootConfig builds it.
|
||||
function busForm(root, rolesDir, id = 'demo') {
|
||||
const b = loadBusiness(id, { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
|
||||
return busBusiness(b, resolved);
|
||||
}
|
||||
|
||||
async function setup(t, mutate) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { harness: ['pi', 'claude-code'] };
|
||||
mutate?.(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
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 reloads = [];
|
||||
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
|
||||
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const revision = () => ask('instances').revision;
|
||||
return { root, rolesDir, file, bus, reloads, ask, revision };
|
||||
}
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
let runs = 0;
|
||||
const bind = (bus, role) => bus.broker.bindLaunch({ business: 'demo', role, run: `run-${++runs}`, harness: 'pi' });
|
||||
|
||||
test('T14 a create goes live with no restart: the new instance binds at once and the answer says restart false', async (t) => {
|
||||
const f = await setup(t);
|
||||
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
assert.equal(added.restart, false);
|
||||
assert.equal(f.reloads.length, 1);
|
||||
assert.equal(f.reloads[0].roles['sec-1'].definition, 'security-reviewer');
|
||||
assert.ok(bind(f.bus, 'sec-1'));
|
||||
});
|
||||
|
||||
test('T14 every committed verb reloads once, limits.set, routes.set, plugins.set and veto.set included, and a refused one does not', async (t) => {
|
||||
const f = await setup(t);
|
||||
// Only the instance writer's verbs carry restart (webby-0094 B1); the other writers' answers don't.
|
||||
assert.equal(Object.hasOwn(f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } }), 'restart'), false);
|
||||
assert.equal(f.reloads.length, 1);
|
||||
assert.equal(f.reloads[0].launch.limits.tokens.instances.coder.limit, 100);
|
||||
f.ask('routes.set', { revision: f.revision(), routes: { coder: { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'low', fallback: null } } });
|
||||
assert.equal(f.reloads.length, 2);
|
||||
assert.equal(f.reloads[1].roles.coder.vars.harness, 'claude-code');
|
||||
f.ask('plugins.set', { revision: f.revision(), plugins: { harness: ['claude-code', 'pi'] } });
|
||||
assert.equal(f.reloads.length, 3);
|
||||
assert.deepEqual(f.reloads[2].plugins.harness, ['claude-code', 'pi']);
|
||||
assert.equal(f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' }).restart, false);
|
||||
assert.equal(f.reloads.length, 4);
|
||||
assert.deepEqual(f.reloads[3].launch.keep, ['coder']);
|
||||
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'ghost', op: 'change', vars: {} })), 'organization-unknown-instance');
|
||||
assert.equal(code(() => f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' })), 'organization-no-change');
|
||||
assert.equal(f.reloads.length, 4);
|
||||
});
|
||||
|
||||
test('T14 a definition change on a running instance refuses instance-running; a vars change still goes live', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
const cap = bind(f.bus, 'sec-1');
|
||||
f.bus.broker.request(cap, { verb: 'role.claim' });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', definition: 'merge-gate' })), 'instance-running');
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(changed.restart, false);
|
||||
assert.equal(f.reloads.at(-1).roles['sec-1'].vars.model, 'claude-sonnet-5-5');
|
||||
});
|
||||
|
||||
test('T14 a file whose credential references differ from the boot copy is not reloaded and the write answers restart true', async (t) => {
|
||||
const f = await setup(t);
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const moved = join(f.root, 'secrets', 'moved-gitea.token');
|
||||
writeFileSync(moved, 'placeholder-not-a-token', { mode: 0o600 });
|
||||
doc.roles.coder.credentials.gitea.file = moved;
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
|
||||
});
|
||||
|
||||
test('T14 a date-only change to a credential reference still reloads: row 199 issue rewrites the date outside the reload', async (t) => {
|
||||
const f = await setup(t);
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
doc.roles.coder.credentials.gitea.rotateBy = '2098-06-01';
|
||||
doc.roles.coder.credentials.vikunja.expires = '2098-06-01';
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'coder', op: 'change', vars: { harness: 'pi', model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(changed.restart, false);
|
||||
assert.equal(f.reloads.length, 1);
|
||||
assert.equal(f.reloads[0].roles.coder.vars.model, 'claude-sonnet-5-5');
|
||||
});
|
||||
|
||||
// Row 194 (brief item 7, T19, sage-1605, sage-1610 a): a human create of a credentialed definition
|
||||
// answers restart true, because credential references are fixed at start. issue refuses until a
|
||||
// restart loads the entry. After one, issue works, the status reads valid, and a vars change after
|
||||
// the issue goes live: issue rewrites the date, and the fixed-at-start compare ignores dates.
|
||||
test('T19 a credentialed create answers restart true; issue works only after a restart, and a later vars change goes live', async (t) => {
|
||||
const f = await setup(t);
|
||||
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
const issue = { agent: 'rev-2', service: 'gitea', token: 'fake_issued_token_0123456789', date: '2099-01-01' };
|
||||
assert.equal(code(() => f.ask('credentials.issue', issue)), 'credential-unknown');
|
||||
|
||||
mkdirSync(join(f.root, 'data-2'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(f.root, 'data-2'), businesses: { demo: busForm(f.root, f.rolesDir) }, organization: { dir: f.root, rolesDir: f.rolesDir, system: systemFor(f.root) } });
|
||||
t.after(() => bus.close());
|
||||
const reloads = [];
|
||||
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
|
||||
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
ask('credentials.issue', issue);
|
||||
assert.equal(bus.credentialStatus('demo', 'rev-2').find(e => e.service === 'gitea').state, 'valid');
|
||||
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).roles['rev-2'].credentials.gitea.rotateBy, '2099-01-01');
|
||||
const changed = ask('instances.set', { revision: ask('instances').revision, instance: 'rev-2', op: 'change', vars: { harness: 'pi', model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(changed.restart, false);
|
||||
assert.equal(reloads.length, 1);
|
||||
assert.equal(reloads[0].roles['rev-2'].vars.model, 'claude-sonnet-5-5');
|
||||
});
|
||||
|
||||
// Row 194 b6 (webby-0094 B1, sage-2241): a credentialed create leaves every later reload refused,
|
||||
// so each instance-writer verb after it answers restart true: the keep, the chief's hire and fire.
|
||||
// Webby's sequence: a keep the running copy never loaded still refuses the chief's fire before step
|
||||
// A, from the file, so the kept instance keeps its claim and isn't left firing.
|
||||
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
|
||||
const f = await setup(t);
|
||||
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
|
||||
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
|
||||
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
const chief = bind(f.bus, 'chief');
|
||||
f.bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const asChief = (verb, args) => f.bus.broker.request(chief, { verb, args });
|
||||
const reviewer = bind(f.bus, 'reviewer');
|
||||
f.bus.broker.request(reviewer, { verb: 'role.claim' });
|
||||
const run = f.ask('instances').instances.reviewer.running;
|
||||
assert.equal(run.length, 1);
|
||||
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
|
||||
assert.equal(added.restart, true);
|
||||
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
|
||||
assert.equal(hired.restart, true);
|
||||
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
|
||||
assert.equal(kept.restart, true);
|
||||
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
|
||||
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
|
||||
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
|
||||
|
||||
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
|
||||
assert.equal(unkept.restart, true);
|
||||
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
|
||||
assert.equal(fired.restart, true);
|
||||
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
|
||||
});
|
||||
|
||||
test('T14 a file whose project root differs from the boot copy is not reloaded, because the Credentials store protects the boot roots', async (t) => {
|
||||
const f = await setup(t);
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const moved = join(f.root, 'project-moved');
|
||||
mkdirSync(moved);
|
||||
doc.projects.stack.root = moved;
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
});
|
||||
|
||||
test('T14 a file whose library mapping differs from the boot copy is not reloaded, because the mirror is ensured at start', async (t) => {
|
||||
const f = await setup(t, doc => { doc.projects.books = mappingEntry(); });
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
doc.projects.books.branch = 'other';
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
// The same library entry reloads.
|
||||
const g = await setup(t, doc => { doc.projects.books = mappingEntry(); });
|
||||
assert.equal(g.ask('instances.set', { revision: g.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' }).restart, false);
|
||||
assert.equal(g.reloads.length, 1);
|
||||
});
|
||||
|
||||
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -249,16 +249,19 @@ test('the runtime refuses an invalid adapter and closes a valid one', async (t)
|
||||
// Each refusal closed what it had opened, the adapter included; the writer lock is free.
|
||||
assert.equal(closed, 4);
|
||||
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
|
||||
let given = null;
|
||||
let given = null, processing = null;
|
||||
const runtime = await startBroker({
|
||||
dataRoot: root,
|
||||
businesses,
|
||||
tasks: async (deps) => {
|
||||
given = Object.keys(deps).sort();
|
||||
processing = deps.processing;
|
||||
return { handle: async () => ({ ok: 1 }), timeout: 1000, close: async () => closed++ };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(given, ['broker', 'businesses', 'credentials']);
|
||||
// Row 191: runtime injects cancellation alongside the existing factory dependencies.
|
||||
assert.deepEqual(given, ['broker', 'businesses', 'credentials', 'processing']);
|
||||
assert.equal(typeof processing.work, 'function');
|
||||
await runtime.close();
|
||||
assert.equal(closed, 5);
|
||||
await assert.rejects(
|
||||
|
||||
@@ -21,6 +21,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
// Row 177 (#1675): the tester's own threads.
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
]);
|
||||
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
|
||||
@@ -93,6 +95,8 @@ test('row 175: W2 as the tester refuses credentials, protected definitions, huma
|
||||
const entries = r.log().length;
|
||||
const refused = [
|
||||
[{ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 'organization-credentials-out-of-scope'],
|
||||
// Row 194 (brief item 7): only the human's add binds credentials. A valid bot doesn't lift it.
|
||||
[{ instance: 'reviewer-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-reviewer-2', botId: 40 } }, 'organization-credentials-out-of-scope'],
|
||||
[{ instance: 'thread-2', op: 'add', definition: 'assistant' }, 'organization-definition-refused'],
|
||||
[{ instance: 'human', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
|
||||
[{ instance: 'tester', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
|
||||
@@ -104,8 +108,8 @@ test('row 175: W2 as the tester refuses credentials, protected definitions, huma
|
||||
assert.equal(r.code(r.tester, 'instances.set', { revision, ...args }), want, JSON.stringify(args));
|
||||
assert.equal(r.bytes(), before, JSON.stringify(args));
|
||||
assert.equal(r.call(r.tester, 'instances').revision, revision);
|
||||
assert.equal(r.log().length, entries, `a refused write logs nothing: ${JSON.stringify(args)}`);
|
||||
}
|
||||
assert.equal(r.log().length, entries, 'a refused write logs nothing');
|
||||
// The control: the same add with an instance var lands, so the refusal above is the var.
|
||||
r.call(r.tester, 'instances.set', { revision, instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.ok(r.log().length > entries);
|
||||
|
||||
@@ -157,7 +157,10 @@ file through the broker's `organization.set`, limited to the keys the human's
|
||||
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
|
||||
staff is the one agent that ends the assistant, which ends every assistant
|
||||
thread run, and it configures the assistant through `assistants` (Assistant
|
||||
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
|
||||
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
|
||||
fires with a reason and no decision, through the broker's `instance.hire` and
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
it is the only role `launch.by` can name. The PM holds no `role.launch`
|
||||
(REQ-LAUNCH-1); it asks the chief of staff for a session.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins } from "../../plugins/src/kinds.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
@@ -20,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -34,7 +35,7 @@ export function businessFilePath(id, dir = configDir()) {
|
||||
return join(dir, "businesses", `${id}.json`);
|
||||
}
|
||||
|
||||
function checkBot(tracker, where) {
|
||||
export function checkBot(tracker, where) {
|
||||
requireObject(tracker, where);
|
||||
rejectUnknownKeys(tracker, ["bot", "botId"], where);
|
||||
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
|
||||
@@ -174,7 +175,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
const launcher = definitions[roles[launch.by].definition];
|
||||
@@ -198,7 +199,29 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
|
||||
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
|
||||
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
|
||||
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
|
||||
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
|
||||
requireId(name, `${where}.keep entry`);
|
||||
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
|
||||
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
|
||||
});
|
||||
const noHire = launch.noHire === undefined ? undefined
|
||||
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
|
||||
return {
|
||||
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
|
||||
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
|
||||
// create or hire can't reuse a name whose decisions outlive it.
|
||||
function checkRetired(retired, roles, file) {
|
||||
return requireDistinctList(retired, `${file} retired`, (name) => {
|
||||
requireId(name, `${file} retired entry`);
|
||||
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
|
||||
});
|
||||
}
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
@@ -278,9 +301,11 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
const plugins = document.plugins === undefined ? {}
|
||||
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
|
||||
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(retired ? { retired } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { credentialKind, isHostKind } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
|
||||
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
|
||||
const WARN_DAYS = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -71,13 +71,15 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
|
||||
let notIssued = false;
|
||||
if (ref.file) {
|
||||
let stat = null;
|
||||
try {
|
||||
stat = lstatSync(ref.file);
|
||||
} catch (e) {
|
||||
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
|
||||
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
|
||||
notIssued = Boolean(ref.store) && e.code === "ENOENT";
|
||||
if (notIssued) warnings.push(`${label}: not issued`);
|
||||
else problems.push(`${label}: not found`);
|
||||
}
|
||||
if (stat) {
|
||||
@@ -106,6 +108,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
|
||||
} else if (env[ref.env] === undefined || env[ref.env] === "") {
|
||||
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
|
||||
}
|
||||
// Row 194 (brief item 7, sage-1605 pick a): a human create writes the create day as the date,
|
||||
// and issue writes the real one. Until then the date describes no token, so a store reference
|
||||
// with no file skips both date checks. Every other reference keeps them.
|
||||
if (notIssued) return { problems, warnings };
|
||||
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
|
||||
if (ref.expires) {
|
||||
const left = days(ref.expires);
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
export { BusinessError } from "./errors.mjs";
|
||||
export {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
|
||||
@@ -1,20 +1,76 @@
|
||||
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
|
||||
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
|
||||
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
|
||||
// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes
|
||||
// through the organization writer, which logs the actor and the old and new values.
|
||||
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
|
||||
// lists and the chief's hire and fire. Every write goes through the organization writer, which
|
||||
// logs the actor and the old and new values.
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { validateBusinessDocument, businessFilePath, checkBot } from "./business.mjs";
|
||||
import { DATE_KEY } from "./credentials.mjs";
|
||||
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { isPlainObject, canonicalJson } from "./util.mjs";
|
||||
import { CREATIVE_DEFINITIONS, END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
|
||||
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
|
||||
const VETO_KEYS = ["revision", "op", "name"];
|
||||
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
|
||||
// Every limits.* key merges by intersection, so it can only narrow.
|
||||
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
|
||||
|
||||
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
|
||||
// The last-listed check runs when the proposed file is validated (deletedDocument). A creative
|
||||
// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The
|
||||
// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse.
|
||||
const endProtected = definition => END_PROTECTED.includes(definition);
|
||||
export function deleteRefusal(document, rolesDir, instance) {
|
||||
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
|
||||
const entry = document.roles[instance];
|
||||
if (endProtected(entry.definition)) return { code: "organization-definition-refused" };
|
||||
const references = [];
|
||||
if (document.launch?.by === instance) references.push("launch.by");
|
||||
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
|
||||
if (references.length > 0) {
|
||||
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
|
||||
}
|
||||
if (document.launch?.keep?.includes(instance)) {
|
||||
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
|
||||
}
|
||||
// Row 194 (brief item 8): delete revokes the agent's store credentials before the write, so an
|
||||
// instance whose every reference is `store` can go, its tracker bot included (sage-1610 b). A
|
||||
// file or env reference stays a hand edit.
|
||||
const refs = Object.values(entry.credentials ?? {});
|
||||
if (refs.some(ref => ref?.store !== true)) {
|
||||
return { code: "organization-credentials-out-of-scope", message: `${instance} holds a file or env credential; deleting it is a hand edit` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
|
||||
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
|
||||
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
|
||||
// A business whose only listed instance is this one refuses with a BusinessError.
|
||||
export function deletedDocument(document, instance) {
|
||||
const next = structuredClone(document);
|
||||
delete next.roles[instance];
|
||||
if (next.launch) {
|
||||
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
|
||||
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
||||
}
|
||||
next.launch.instances = next.launch.instances.filter(name => name !== instance);
|
||||
const tokens = next.launch.limits?.tokens?.instances;
|
||||
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
|
||||
}
|
||||
next.retired = [...(next.retired ?? []), instance];
|
||||
return next;
|
||||
}
|
||||
|
||||
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
|
||||
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
|
||||
@@ -23,20 +79,63 @@ const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
|
||||
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
|
||||
|
||||
function definitionFor(rolesDir, name) {
|
||||
// Row 194 (brief item 7): the human's add passes credentials true and lifts it. The tester's add,
|
||||
// change and hire keep it.
|
||||
function definitionFor(rolesDir, name, { credentials = false } = {}) {
|
||||
if (closed(name)) throw new OrganizationError("organization-definition-refused");
|
||||
const definition = loadRole(rolesDir, name);
|
||||
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
|
||||
if (definition.credentials.length > 0) {
|
||||
if (!credentials && definition.credentials.length > 0) {
|
||||
throw new OrganizationError("organization-credentials-out-of-scope",
|
||||
`${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition.
|
||||
// Each declared service gets a store reference dated the create day (UTC), and no file: the agent
|
||||
// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
|
||||
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
|
||||
function createdCredentials(old, instance, definition, args) {
|
||||
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
|
||||
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
|
||||
const out = {};
|
||||
if (services.includes("vikunja")) {
|
||||
if (!Object.hasOwn(args, "tracker")) {
|
||||
throw new OrganizationError("organization-tracker-required", `${definition.name} uses vikunja; add needs tracker {bot, botId}, an existing Vikunja bot`);
|
||||
}
|
||||
let tracker;
|
||||
try {
|
||||
tracker = checkBot(args.tracker, "tracker");
|
||||
} catch (e) {
|
||||
if (e.name === "BusinessError") throw new OrganizationError("invalid-request", e.message);
|
||||
throw e;
|
||||
}
|
||||
// business.mjs checks the same after the write: no two instances and not the sync bot.
|
||||
const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
|
||||
if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
|
||||
for (const [name, other] of taken) {
|
||||
if (other.bot === tracker.bot || other.botId === tracker.botId) {
|
||||
throw new OrganizationError("organization-tracker-bot-taken", `${name} already uses bot ${other.bot} (botId ${other.botId})`);
|
||||
}
|
||||
}
|
||||
out.tracker = tracker;
|
||||
} else if (Object.hasOwn(args, "tracker")) {
|
||||
throw new OrganizationError("invalid-request", `${definition.name} uses no vikunja credential, so it takes no tracker`);
|
||||
}
|
||||
if (services.length > 0) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
out.credentials = Object.fromEntries(services.map(service => [service, { store: true, [DATE_KEY[service]]: today }]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
|
||||
// checkRoutes, row 65).
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null }) {
|
||||
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
|
||||
// services) revokes the instance's credentials for a fire or a delete and throws
|
||||
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
|
||||
// nothing.
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
@@ -68,14 +167,20 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
launched: launch ? launch.instances.includes(name) : false,
|
||||
launcher: launch ? launch.by === name : false,
|
||||
protected: closed(entry.definition),
|
||||
kept: (launch?.keep ?? []).includes(name),
|
||||
endProtected: endProtected(entry.definition),
|
||||
}]));
|
||||
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
|
||||
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
|
||||
return {
|
||||
business, revision, launch: launch !== null, instances, definitions: definitions(),
|
||||
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
// args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }.
|
||||
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
|
||||
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
|
||||
// the instance's vars whole. The answer's restart is true when the running bus must restart
|
||||
// before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch).
|
||||
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
|
||||
// business after the write and sets restart from that reload (runtime.mjs).
|
||||
function set(business, args, actor) {
|
||||
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
@@ -84,7 +189,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
const { instance, op } = args;
|
||||
if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request");
|
||||
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
|
||||
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
|
||||
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
|
||||
if (instance === "human" || instance === "tester") {
|
||||
@@ -95,17 +200,30 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
|
||||
|
||||
const old = writer.read(business).document;
|
||||
if (op === "delete") {
|
||||
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
|
||||
const services = Object.keys(old.roles[instance]?.credentials ?? {});
|
||||
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
|
||||
const result = commit(business, old, planDelete(old, instance), { revision: args.revision, actor, beforeWrite });
|
||||
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
|
||||
// can't reach (row 199 A5).
|
||||
const bot = old.roles[instance]?.tracker?.bot;
|
||||
return bot ? { ...result, tracker: { bot, removeByHand: `remove the Vikunja bot user ${bot} and any token it holds by hand in Vikunja` } } : result;
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
const exists = Object.hasOwn(next.roles, instance);
|
||||
let redefined = false;
|
||||
if (op === "add") {
|
||||
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
|
||||
if (exists) throw new OrganizationError("organization-instance-exists");
|
||||
creativeHumanOnly(old, actor, instance, args.definition);
|
||||
definitionFor(rolesDir, args.definition);
|
||||
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
|
||||
// Only the human's add binds credentials. The tester keeps the refusal (row 175 W2): the bus
|
||||
// names the tester's actor "tester", which no human or role may be called.
|
||||
const definition = definitionFor(rolesDir, args.definition, { credentials: actor !== "tester" });
|
||||
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
redefined = true;
|
||||
Object.assign(next.roles[instance], createdCredentials(old, instance, definition, args));
|
||||
} else {
|
||||
if (Object.hasOwn(args, "tracker")) throw new OrganizationError("invalid-request", "tracker is set only by add");
|
||||
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
|
||||
if (!exists) throw new OrganizationError("organization-unknown-instance");
|
||||
const entry = next.roles[instance];
|
||||
@@ -123,8 +241,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
|
||||
}
|
||||
entry.definition = args.definition;
|
||||
redefined = true;
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
|
||||
}
|
||||
if (Object.hasOwn(args, "launch")) {
|
||||
@@ -133,6 +250,124 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
|
||||
else if (!listed.includes(instance)) listed.push(instance);
|
||||
}
|
||||
return commit(business, old, next, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
|
||||
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
|
||||
function planDelete(document, instance) {
|
||||
const refusal = deleteRefusal(document, rolesDir, instance);
|
||||
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
|
||||
return deletedDocument(document, instance);
|
||||
}
|
||||
|
||||
function checkDelete(business, instance) {
|
||||
const { document, revision } = writer.read(business);
|
||||
const next = planDelete(document, instance);
|
||||
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
||||
return { revision };
|
||||
}
|
||||
|
||||
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
|
||||
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
|
||||
function veto(business, args, actor) {
|
||||
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
|
||||
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
|
||||
const old = writer.read(business).document;
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const next = structuredClone(old);
|
||||
const { op, name } = args;
|
||||
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
|
||||
const list = next.launch[key] ?? [];
|
||||
if (op === "keep") {
|
||||
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
||||
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
const add = op === "keep" || op === "nohire";
|
||||
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
|
||||
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
|
||||
return commit(business, old, next, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
|
||||
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
||||
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
||||
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
|
||||
function hire(business, args, { actor, context }) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name, definition } = args;
|
||||
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
|
||||
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
|
||||
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
|
||||
}
|
||||
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
|
||||
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
|
||||
// change. A hire is the chief's, so it refuses one here.
|
||||
if (CREATIVE_DEFINITIONS.includes(definition)) {
|
||||
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
|
||||
}
|
||||
definitionFor(rolesDir, definition);
|
||||
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
|
||||
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
||||
const next = structuredClone(old);
|
||||
if (Object.hasOwn(old.roles, name)) {
|
||||
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
|
||||
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
|
||||
throw new OrganizationError("organization-instance-exists");
|
||||
}
|
||||
} else {
|
||||
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
}
|
||||
next.launch.instances.push(name);
|
||||
return commit(business, old, next, { revision, actor, context });
|
||||
}
|
||||
|
||||
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
|
||||
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
|
||||
function fire(business, args, { actor, context }) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
const { name } = args;
|
||||
fireChecks(old, name);
|
||||
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
|
||||
const services = Object.keys(old.roles[name].credentials ?? {});
|
||||
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
|
||||
// its tokens revoked, with no write. A revoke is safe to repeat.
|
||||
if (!old.launch.instances.includes(name)) {
|
||||
const revoked = revoke ? revoke(business, name, "fire", services) : null;
|
||||
return { ...read(business), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
||||
let revoked = null;
|
||||
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
|
||||
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
|
||||
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
|
||||
}
|
||||
|
||||
// Row 194 b6 (webby-0094 B1): the broker runs fire's checks against the file before step A stops
|
||||
// anything, so a keep its own copy hasn't loaded (a refused reload) refuses there and not at
|
||||
// step B, which would leave the instance firing. No write.
|
||||
function checkFire(business, name) {
|
||||
const { document, revision } = writer.read(business);
|
||||
fireChecks(document, name);
|
||||
return { revision };
|
||||
}
|
||||
|
||||
function fireChecks(old, name) {
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
||||
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
||||
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
||||
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
|
||||
if (old.launch.instances.includes(name) && old.launch.instances.length === 1) {
|
||||
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
||||
}
|
||||
}
|
||||
|
||||
// The checks every write runs, then beforeWrite (a fire's or a delete's revoke), then the write. A
|
||||
// check that refuses revokes nothing, and a revoke that throws writes nothing.
|
||||
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
||||
@@ -160,9 +395,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
}
|
||||
}
|
||||
}
|
||||
const receipt = writer.write(business, next, { revision: args.revision, actor });
|
||||
return { ...read(business), transaction: receipt.transaction, restart: redefined };
|
||||
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
|
||||
// so a write since then makes it stale. The revision is checked here, before the revoke, because
|
||||
// the writer's own check comes after it. A write between this read and the writer's lock still
|
||||
// revokes and then refuses (BUILD.md follow-up).
|
||||
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
||||
if (beforeWrite) beforeWrite();
|
||||
const receipt = writer.write(business, next, { revision, actor, context });
|
||||
return { ...read(business), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
return { writer, read, set };
|
||||
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,16 @@ export function creativeHumanOnly(document, actor, instance, definition) {
|
||||
}
|
||||
}
|
||||
|
||||
// Row 194 (D2.4 B7, webby-0094 N3): only the human changes a kept instance's model, through the
|
||||
// instance's own key or by re-pointing the launch.models entry that key names (the chief and the
|
||||
// tester are refused, as row 213 refuses them a creative instance's). The check reads the file, so a
|
||||
// keep the broker's copy hasn't loaded holds too.
|
||||
function keptHumanOnly(document, actor, instance) {
|
||||
if (actor !== document.human && (document.launch?.keep ?? []).includes(instance)) {
|
||||
throw new OrganizationError("instance-kept", `${instance} is kept; only the human changes its model`);
|
||||
}
|
||||
}
|
||||
|
||||
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
||||
// the chief of staff may change. holder and credentials stay hand edits.
|
||||
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
|
||||
@@ -184,7 +194,10 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
if (typeof key !== "string") continue;
|
||||
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
|
||||
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
|
||||
if (was !== is) creativeHumanOnly(old.document, actor, instance, entry.definition);
|
||||
if (was !== is) {
|
||||
creativeHumanOnly(old.document, actor, instance, entry.definition);
|
||||
keptHumanOnly(old.document, actor, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
next.launch.models = args.models;
|
||||
@@ -199,6 +212,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
|
||||
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
|
||||
}
|
||||
keptHumanOnly(old.document, actor, instance);
|
||||
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
|
||||
}
|
||||
}
|
||||
@@ -277,6 +291,8 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
for (const [instance, route] of Object.entries(args.routes)) {
|
||||
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
|
||||
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
|
||||
// webby-0095 N1, sage-2248: a route is a second write of a kept instance's model.
|
||||
keptHumanOnly(old.document, actor, instance);
|
||||
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
|
||||
@@ -175,8 +175,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
|
||||
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
|
||||
// date, reason), never a value; the other setters pass none.
|
||||
function write(id, document, { revision, actor, detail }) {
|
||||
// date, reason), never a value; the other setters pass none. context, when given, is logged with
|
||||
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
|
||||
function write(id, document, { revision, actor, detail, context }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
@@ -208,7 +209,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
durable(candidate, bytes);
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, business: id, before, after,
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
// ceilings. Version 1 files still load: they carry no authority, so every
|
||||
// vocabulary action is gated for them.
|
||||
|
||||
import { lstatSync } from "node:fs";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, unhireableAction,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function checkTools(tools) {
|
||||
@@ -76,6 +76,27 @@ function checkSkills(skills) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
|
||||
export { unhireableAction };
|
||||
|
||||
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
|
||||
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
|
||||
// is validated without its own "hireable" check, so a list can't recurse. A creative definition
|
||||
// (row 213, M10) is never hireable: only the human adds one.
|
||||
function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
@@ -159,8 +180,9 @@ function checkCreative(role) {
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
|
||||
export function validateRoleDocument(document, file) {
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
|
||||
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
|
||||
export function validateRoleDocument(document, file, options = {}) {
|
||||
requireObject(document, "role");
|
||||
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
@@ -173,7 +195,7 @@ export function validateRoleDocument(document, file) {
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
|
||||
});
|
||||
}
|
||||
for (const key of V2_REQUIRED) {
|
||||
@@ -191,6 +213,9 @@ export function validateRoleDocument(document, file) {
|
||||
credentials: checkCredentials(document.credentials),
|
||||
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
||||
skills: document.skills === undefined ? [] : checkSkills(document.skills),
|
||||
hireable: document.hireable === undefined ? []
|
||||
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
|
||||
: checkHireable(document.hireable, file),
|
||||
};
|
||||
return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ export const ACTIONS = Object.freeze([
|
||||
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
|
||||
// staff set names once its role file grants that within-role.
|
||||
"instance.name",
|
||||
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
|
||||
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
@@ -60,7 +64,7 @@ export const ACTIONS = Object.freeze([
|
||||
// action joins this list only by the reviewed commit that names it routed.
|
||||
// git.merge.protected, policy.change, prd.approve and role.revoke left the
|
||||
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
|
||||
// under REQ-MSG-2 (1.5).
|
||||
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
|
||||
export const ROUTED = Object.freeze([
|
||||
"task.create",
|
||||
"task.assign",
|
||||
@@ -84,6 +88,9 @@ export const ROUTED = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
]);
|
||||
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
@@ -93,6 +100,30 @@ export const ROUTED = Object.freeze([
|
||||
// git.push.remote, host.sudo (row 210) and deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
|
||||
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
|
||||
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
|
||||
export const HIRE_ALLOWED = Object.freeze([
|
||||
"message.send",
|
||||
"record.write",
|
||||
"review.request",
|
||||
"task.update.assigned",
|
||||
"task.scope.change",
|
||||
"git.push.working",
|
||||
]);
|
||||
|
||||
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
|
||||
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
|
||||
// use it. It lives here because the session sandbox mounts this file and not role.mjs
|
||||
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
|
||||
export function unhireableAction(role) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
|
||||
if (outside !== undefined) return outside;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
// accepts for tasks.
|
||||
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
||||
|
||||
@@ -286,3 +286,36 @@ test("persona vars: agent layer, creative definition only, both or neither", ()
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
|
||||
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
|
||||
const plain = check();
|
||||
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
|
||||
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
|
||||
assert.equal(Object.hasOwn(plain, "retired"), false);
|
||||
const business = check((d) => {
|
||||
d.launch.keep = ["coder", "chief"];
|
||||
d.launch.noHire = ["reviewer", "no-such-role"];
|
||||
d.retired = ["old-coder"];
|
||||
});
|
||||
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
|
||||
// A stale noHire entry is harmless: only the id pattern is checked.
|
||||
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
|
||||
assert.deepEqual(business.retired, ["old-coder"]);
|
||||
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
|
||||
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
|
||||
});
|
||||
|
||||
test("row 194 each bad keep, noHire or retired entry refuses", () => {
|
||||
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
|
||||
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
|
||||
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
|
||||
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
|
||||
/launch\.keep names assistant, whose definition assistant can't be kept/);
|
||||
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
|
||||
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
|
||||
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
|
||||
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
|
||||
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
|
||||
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ".
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
|
||||
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
|
||||
// (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3,
|
||||
// sage-1471, mos-1089). A role file that differs from it fails.
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
|
||||
hireable: Object.freeze([]),
|
||||
});
|
||||
|
||||
// The fixture business's chief-of-staff instance, which is also its launcher.
|
||||
@@ -85,9 +88,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
|
||||
assert.deepEqual(record.credentials, {});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
|
||||
const record = resolved(loadFixture());
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"];
|
||||
for (const action of ACTIONS) {
|
||||
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
|
||||
}
|
||||
@@ -139,6 +142,8 @@ test("the contract names no duty to talk to the human and the pm contract no lau
|
||||
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
|
||||
assert.doesNotMatch(chief, /\bJason\b/);
|
||||
assert.match(chief, /never talk to the user/);
|
||||
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
|
||||
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
|
||||
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
|
||||
assert.doesNotMatch(pm, /^- Launch /m);
|
||||
assert.match(pm, /You don't launch sessions/);
|
||||
|
||||
@@ -117,6 +117,29 @@ test("validate: credential reference problems exit 2 and name each one", () => {
|
||||
assert.match(r.err, /3 credential reference problem/);
|
||||
});
|
||||
|
||||
// Row 194 (brief item 7, sage-1605 pick a): a human create writes store references dated the
|
||||
// create day. Until issue writes a file, the date describes no token, so validate skips it. An
|
||||
// issued store reference keeps the date check.
|
||||
test("validate: a not-issued store agent dated today passes, and an issued one with a past expiry fails", () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const s = setup((doc) => {
|
||||
doc.roles.coder.credentials = { gitea: { store: true, rotateBy: today }, vikunja: { store: true, expires: today } };
|
||||
});
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.match(r.err, /gitea file .*secrets\/agents\/acme\/coder\/gitea: not issued/);
|
||||
assert.match(r.err, /vikunja file .*secrets\/agents\/acme\/coder\/vikunja: not issued/);
|
||||
assert.doesNotMatch(r.err, /expire|rotation/);
|
||||
|
||||
const issued = setup((doc) => {
|
||||
doc.roles.coder.credentials = { gitea: { store: true, rotateBy: today }, vikunja: { store: true, expires: "2026-01-01" } };
|
||||
});
|
||||
tokenFile(join(issued.root, "config", "secrets", "agents", "acme", "coder"), "vikunja");
|
||||
const e = run(issued, "validate", "acme");
|
||||
assert.equal(e.code, 2);
|
||||
assert.match(e.err, /vikunja file .*coder\/vikunja: expired on 2026-01-01/);
|
||||
});
|
||||
|
||||
test("validate: a token file inside the repository is refused", () => {
|
||||
const s = setup((doc) => { doc.roles.pm.credentials.gitea.file = join(REPO, "roles", "pm.md"); });
|
||||
const r = run(s, "validate", "acme");
|
||||
|
||||
@@ -33,6 +33,11 @@ test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", ()
|
||||
assert.ok(Object.isFrozen(END_PROTECTED));
|
||||
});
|
||||
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire left the hard waits and are routed.
|
||||
test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
|
||||
@@ -61,7 +61,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
|
||||
const view = f.settings.read("acme");
|
||||
assert.match(view.revision, /^[a-f0-9]{64}$/);
|
||||
assert.equal(view.launch, true);
|
||||
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false });
|
||||
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false });
|
||||
assert.equal(view.instances.chief.launcher, true);
|
||||
assert.equal(view.instances.assistant.protected, true);
|
||||
assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true });
|
||||
@@ -71,16 +71,16 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
|
||||
assert.equal(Object.hasOwn(view.definitions, "conductor-policy"), false);
|
||||
});
|
||||
|
||||
test("row 165 add: two instances join, validate exits 0, the log names the human, and the bus restart is reported", () => {
|
||||
test("row 165 add: two instances join, validate exits 0, the log names the human, and the writer asks for no restart (row 194, D3)", () => {
|
||||
const f = fixture();
|
||||
const view = f.settings.read("acme");
|
||||
const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason");
|
||||
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false });
|
||||
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false });
|
||||
assert.notEqual(added.revision, view.revision);
|
||||
assert.match(added.transaction, /\S/);
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(added.restart, false);
|
||||
const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason");
|
||||
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false });
|
||||
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false, endProtected: false });
|
||||
const file = JSON.parse(readFileSync(f.file, "utf8"));
|
||||
assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" });
|
||||
assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]);
|
||||
@@ -95,11 +95,11 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
|
||||
assert.equal(second.actor, "jason");
|
||||
});
|
||||
|
||||
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
|
||||
test("row 165 change: definition, vars and launch membership log before and after; none asks for a restart, the bus reloads (row 194, D3)", () => {
|
||||
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
|
||||
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
|
||||
assert.equal(redefined.restart, true);
|
||||
assert.equal(redefined.restart, false);
|
||||
let entry = f.log().at(-1);
|
||||
assert.ok(entry.changes.some(c => c.path === "roles.sec-1.definition" && c.old === "security-reviewer" && c.new === "merge-gate"));
|
||||
const revarred = f.settings.set("acme", { revision: redefined.revision, instance: "sec-1", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5" } }, "jason");
|
||||
@@ -147,11 +147,51 @@ test("row 165 refuses each validation failure and leaves the file's bytes alone"
|
||||
});
|
||||
|
||||
// mos-0823: no instance is written without its credential binding, and nothing binds one here.
|
||||
test("row 165 refuses an added instance whose definition declares credentials, and says why", () => {
|
||||
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition
|
||||
// writes store references dated today and no file. A vikunja definition takes an existing bot,
|
||||
// checked on the add path before the file's own validation: its shape, and no other instance or
|
||||
// the sync bot using its name or id. A definition without vikunja takes none.
|
||||
test("T19 add of a credentialed definition writes store references dated today, names its bot, and validate exits 0", () => {
|
||||
const f = fixture();
|
||||
refuses(f, { instance: "reviewer-2", op: "add", definition: "reviewer" }, "organization-credentials-out-of-scope");
|
||||
assert.throws(() => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "reviewer-2", op: "add", definition: "reviewer" }, "jason"),
|
||||
/reviewer declares gitea and vikunja credentials/);
|
||||
const add = (instance, definition, extra = {}) => ({ instance, op: "add", definition, ...extra });
|
||||
refuses(f, add("reviewer-2", "reviewer"), "organization-tracker-required");
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "reviewer-bot", botId: 40 } }), "invalid-request");
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-reviewer-2", botId: 0 } }), "invalid-request");
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-reviewer", botId: 40 } }), "organization-tracker-bot-taken");
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-reviewer-2", botId: f.doc.roles.pm.tracker.botId } }), "organization-tracker-bot-taken");
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-sync", botId: 40 } }), "organization-tracker-bot-taken");
|
||||
refuses(f, add("sec-1", "security-reviewer", { tracker: { bot: "bot-acme-sec-1", botId: 40 } }), "invalid-request");
|
||||
refuses(f, { instance: "coder", op: "change", vars: {}, tracker: { bot: "bot-acme-coder-2", botId: 40 } }, "invalid-request");
|
||||
assert.throws(() => f.settings.hire("acme", { name: "reviewer-3", definition: "reviewer" }, { actor: "chief", context: {} }),
|
||||
e => e.code === "organization-credentials-out-of-scope");
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const done = f.settings.set("acme", { revision: f.settings.read("acme").revision, ...add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-reviewer-2", botId: 40 } }) }, "jason");
|
||||
assert.equal(done.restart, false);
|
||||
assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).roles["reviewer-2"], {
|
||||
definition: "reviewer",
|
||||
tracker: { bot: "bot-acme-reviewer-2", botId: 40 },
|
||||
credentials: { gitea: { store: true, rotateBy: today }, vikunja: { store: true, expires: today } },
|
||||
});
|
||||
const validated = f.validate();
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.match(validated.stderr, /reviewer-2\/vikunja: not issued/);
|
||||
// The Console's create form keeps listing it as not accepted.
|
||||
assert.equal(f.settings.read("acme").definitions.reviewer.accepted, false);
|
||||
});
|
||||
|
||||
// webby-0094 N2: a definition that also declares the host service gets store references for its
|
||||
// token services only. host is a grant, not a reference, and an "undefined" date key refused the add.
|
||||
test("T19 add of a definition that declares host writes references for its token services only", () => {
|
||||
const f = fixture();
|
||||
const file = join(f.rolesDir, "reviewer.json");
|
||||
const role = JSON.parse(readFileSync(file, "utf8"));
|
||||
role.credentials.push({ service: "host" });
|
||||
writeFileSync(file, JSON.stringify(role));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "reviewer-2", op: "add", definition: "reviewer", tracker: { bot: "bot-acme-reviewer-2", botId: 40 } }, "jason");
|
||||
assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).roles["reviewer-2"].credentials,
|
||||
{ gitea: { store: true, rotateBy: today }, vikunja: { store: true, expires: today } });
|
||||
});
|
||||
|
||||
test("row 165 refuses a definition change to or from one that declares credentials", () => {
|
||||
@@ -245,7 +285,7 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
e => e.code === "organization-creative-human-only", `${actor} redefine`);
|
||||
}
|
||||
let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason");
|
||||
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false });
|
||||
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true });
|
||||
for (const actor of ["chief-of-staff.chief", "tester"]) {
|
||||
for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) {
|
||||
assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor),
|
||||
@@ -254,7 +294,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
}
|
||||
done = f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", definition: "ideas", vars: { ...persona, "persona.name": "Bean Two" } }, "jason");
|
||||
assert.equal(done.instances.bean.definition, "ideas");
|
||||
assert.equal(done.restart, true);
|
||||
// Row 194 (D3): the writer's restart is false; the bus sets it from its reload, and bean isn't running.
|
||||
assert.equal(done.restart, false);
|
||||
// A persona var on a definition that isn't creative is refused by the validator (D1).
|
||||
refuses(f, { instance: "bean", op: "change", definition: "security-reviewer" }, "BusinessError");
|
||||
assert.equal(f.validate().status, 0);
|
||||
|
||||
@@ -142,6 +142,41 @@ test('row 65 a file with harness vars and no plugins.harness loads and its views
|
||||
const moved=settings.set('acme',{revision:view.revision,instances:{coder:'opus'}},'jason');assert.equal(moved.instances.coder.modelKey,'opus');
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:moved.revision,routes:{coder:{harness:'pi',model:'opus',thinking:'low',fallback:null}}},'jason'),code('harness-off'));
|
||||
});
|
||||
// Row 194 (D2.4 B7, webby-0094 N3): a kept instance's model is the human's, through its own key in
|
||||
// instances or a models key it names, checked against the file. A key no kept instance names stays
|
||||
// the chief's.
|
||||
test('B7 kept instance: the models keys it names and the instances key are the human\'s',async()=>{
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.launch.keep=['coder'];writeJson(f.file,doc);
|
||||
const g={...f,doc};const {settings}=await routeSettings(g);const file=()=>readFileSync(f.file,'utf8');
|
||||
const chief='chief-of-staff.chief';let {revision}=settings.read('acme');
|
||||
const models=structuredClone(settings.read('acme').models);
|
||||
const key=doc.roles.coder.vars.model;
|
||||
const moved={...models,[key]:{provider:'proxy',model:'claude-sonnet-5-5'}};
|
||||
for(const actor of [chief,'tester']){
|
||||
const bytes=file();
|
||||
assert.throws(()=>settings.set('acme',{revision,models:moved},actor),code('instance-kept'),actor);
|
||||
assert.throws(()=>settings.set('acme',{revision,instances:{coder:'opus'}},actor),code('instance-kept'),actor);
|
||||
assert.equal(file(),bytes,actor);
|
||||
}
|
||||
({revision}=settings.set('acme',{revision,models:{...models,spare:{provider:'proxy',model:'claude-opus-5-5'}}},chief));
|
||||
({revision}=settings.set('acme',{revision,models:{...settings.read('acme').models,[key]:moved[key]}},'jason'));
|
||||
settings.set('acme',{revision,instances:{coder:'opus'}},'jason');
|
||||
assert.equal(JSON.parse(file()).roles.coder.vars.model,'opus');
|
||||
});
|
||||
// webby-0095 N1 (sage-2248): routes.set writes a kept instance's model too, so it's the human's.
|
||||
test('B7 kept instance: its route is the human\'s',async()=>{
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.launch.keep=['coder'];writeJson(f.file,doc);
|
||||
const g={...f,doc};const {settings}=await routeSettings(g);const file=()=>readFileSync(f.file,'utf8');
|
||||
const {revision}=settings.routes('acme');
|
||||
const route={harness:'pi',model:'opus',thinking:'low',fallback:null};
|
||||
for(const actor of ['chief-of-staff.chief','tester']){
|
||||
const bytes=file();
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision,routes:{coder:route}},actor),code('instance-kept'),actor);
|
||||
assert.equal(file(),bytes,actor);
|
||||
}
|
||||
settings.setRoutes('acme',{revision,routes:{coder:route}},'jason');
|
||||
assert.equal(JSON.parse(file()).roles.coder.vars.model,'opus');
|
||||
});
|
||||
// Row 213 (C2-08, #1714, M10): a creative instance is the human's. The chief of staff (and the tester)
|
||||
// can't reach its model through the models table, the instances key or a route; the human can,
|
||||
// except through the instances key, which refuses a creative instance for everyone.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Row 194 (#1692): the organization writer's delete (V5), the human's veto lists (V6) and the
|
||||
// chief's hire and fire (D2.2). The broker's identity, decision and allowlist checks are bus tests.
|
||||
// A refused write leaves the file's bytes alone.
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
const T0 = "2026-10-18T07:00:00.000Z";
|
||||
const CONTEXT = Object.freeze({ instance: "chief", run: "run-1", reason: "a security review is due" });
|
||||
|
||||
// sec-1 is a listed security-reviewer: no credential, no tracker bot, so the writer may delete it.
|
||||
function fixture(mutate) {
|
||||
const root = tmp();
|
||||
roots.push(root);
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, "acme");
|
||||
doc.roles["sec-1"] = { definition: "security-reviewer" };
|
||||
doc.launch.instances.push("sec-1");
|
||||
mutate?.(doc, root);
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
|
||||
const config = systemConfig(root);
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
|
||||
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
|
||||
});
|
||||
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
|
||||
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
|
||||
const revision = () => settings.read("acme").revision;
|
||||
return { root, file, settings, validate, log, onDisk, revision };
|
||||
}
|
||||
|
||||
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
|
||||
function refuses(f, act, code) {
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(act, e => (code === "BusinessError" ? e.name === code : e.code === code), code);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, code);
|
||||
}
|
||||
const del = (f, instance, extra = {}) => () => f.settings.set("acme", { revision: f.revision(), instance, op: "delete", ...extra }, "jason");
|
||||
const veto = (f, op, name, actor = "human") => f.settings.veto("acme", { revision: f.revision(), op, name }, actor);
|
||||
const hire = (f, args) => f.settings.hire("acme", args, { actor: "chief", context: CONTEXT });
|
||||
const fire = (f, name) => f.settings.fire("acme", { name }, { actor: "chief", context: CONTEXT });
|
||||
|
||||
test("T6 delete refuses in the design's order and leaves the file's bytes alone", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.keep = ["sec-3", "coder"];
|
||||
});
|
||||
refuses(f, del(f, "ghost"), "organization-unknown-instance");
|
||||
refuses(f, del(f, "assistant"), "organization-definition-refused");
|
||||
refuses(f, del(f, "chief"), "organization-instance-referenced");
|
||||
refuses(f, del(f, "pm"), "organization-instance-referenced");
|
||||
refuses(f, del(f, "cto"), "organization-instance-referenced");
|
||||
assert.throws(del(f, "cto"), /cto is arbiters\.technical; change that first/);
|
||||
refuses(f, del(f, "sec-3"), "organization-instance-kept");
|
||||
// coder declares credentials and is kept: kept is checked first, so the veto is named.
|
||||
refuses(f, del(f, "coder"), "organization-instance-kept");
|
||||
refuses(f, del(f, "reviewer"), "organization-credentials-out-of-scope");
|
||||
refuses(f, del(f, "sec-1", { definition: "security-reviewer" }), "invalid-request");
|
||||
// checkDelete runs the same checks with no write.
|
||||
refuses(f, () => f.settings.checkDelete("acme", "sec-3"), "organization-instance-kept");
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.deepEqual(f.settings.checkDelete("acme", "sec-1"), { revision: f.revision() });
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
test("T6 delete refuses the last listed instance", () => {
|
||||
const f = fixture(doc => { doc.launch.instances = ["sec-1"]; });
|
||||
refuses(f, del(f, "sec-1"), "BusinessError");
|
||||
refuses(f, () => f.settings.checkDelete("acme", "sec-1"), "BusinessError");
|
||||
});
|
||||
|
||||
test("T6 a clean delete removes the entry and its own references, adds retired, and the name can't come back", () => {
|
||||
const f = fixture(doc => { doc.launch.limits.tokens = { instances: { "sec-1": { limit: 100, since: T0 }, coder: { limit: 50, since: T0 } } }; });
|
||||
const done = del(f, "sec-1")();
|
||||
assert.equal(Object.hasOwn(done.instances, "sec-1"), false);
|
||||
assert.deepEqual(done.retired, ["sec-1"]);
|
||||
assert.equal(done.restart, false);
|
||||
const file = f.onDisk();
|
||||
assert.equal(Object.hasOwn(file.roles, "sec-1"), false);
|
||||
assert.deepEqual(file.launch.instances, ["coder", "reviewer"]);
|
||||
assert.deepEqual(file.launch.limits.tokens.instances, { coder: { limit: 50, since: T0 } });
|
||||
assert.deepEqual(file.retired, ["sec-1"]);
|
||||
const run = f.validate();
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "jason");
|
||||
assert.equal(entry.transaction, done.transaction);
|
||||
assert.ok(entry.changes.some(c => c.path === "roles.sec-1" && c.newPresent === false));
|
||||
assert.ok(entry.changes.some(c => c.path === "retired" && c.oldPresent === false));
|
||||
refuses(f, () => f.settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"),
|
||||
"organization-instance-retired");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-retired");
|
||||
refuses(f, del(f, "sec-1"), "organization-unknown-instance");
|
||||
});
|
||||
|
||||
test("T7 veto: keep, unkeep, nohire and hire each write once, a repeat is no change, and the log names the human", () => {
|
||||
const f = fixture();
|
||||
let view = veto(f, "keep", "sec-1");
|
||||
assert.equal(view.instances["sec-1"].kept, true);
|
||||
assert.equal(view.restart, false);
|
||||
assert.deepEqual(f.onDisk().launch.keep, ["sec-1"]);
|
||||
let entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "human");
|
||||
assert.ok(entry.changes.some(c => c.path === "launch.keep" && c.oldPresent === false && c.new.includes("sec-1")));
|
||||
refuses(f, () => veto(f, "keep", "sec-1"), "organization-no-change");
|
||||
view = veto(f, "unkeep", "sec-1");
|
||||
assert.equal(view.instances["sec-1"].kept, false);
|
||||
assert.deepEqual(f.onDisk().launch.keep, []);
|
||||
refuses(f, () => veto(f, "unkeep", "sec-1"), "organization-no-change");
|
||||
view = veto(f, "nohire", "merge-gate");
|
||||
assert.deepEqual(view.noHire, ["merge-gate"]);
|
||||
// A name with no role file is accepted: the list forbids, it doesn't declare.
|
||||
view = veto(f, "nohire", "no-such-role");
|
||||
assert.deepEqual(view.noHire, ["merge-gate", "no-such-role"]);
|
||||
refuses(f, () => veto(f, "nohire", "merge-gate"), "organization-no-change");
|
||||
view = veto(f, "hire", "merge-gate");
|
||||
assert.deepEqual(view.noHire, ["no-such-role"]);
|
||||
refuses(f, () => veto(f, "hire", "merge-gate"), "organization-no-change");
|
||||
entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "human");
|
||||
assert.ok(entry.changes.some(c => c.path === "launch.noHire"));
|
||||
assert.equal(f.validate().status, 0);
|
||||
});
|
||||
|
||||
test("T7 veto refuses an unknown or END_PROTECTED keep, a bad request, a stale revision and a business without launch", () => {
|
||||
const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; });
|
||||
refuses(f, () => veto(f, "keep", "ghost"), "organization-unknown-instance");
|
||||
refuses(f, () => veto(f, "keep", "assistant"), "organization-definition-refused");
|
||||
const revision = f.revision();
|
||||
for (const bad of [
|
||||
{ revision, op: "keep" }, { revision, op: "forbid", name: "sec-1" }, { revision, op: "keep", name: "Bad Name" },
|
||||
{ revision: "stale", op: "keep", name: "sec-1" }, { revision, op: "keep", name: "sec-1", by: "chief" },
|
||||
]) refuses(f, () => f.settings.veto("acme", bad, "human"), "invalid-request");
|
||||
refuses(f, () => f.settings.veto("acme", null, "human"), "invalid-request");
|
||||
veto(f, "keep", "sec-1");
|
||||
refuses(f, () => f.settings.veto("acme", { revision, op: "nohire", name: "coder" }, "human"), "revision-conflict");
|
||||
const none = fixture(doc => { delete doc.launch; doc.roles["sec-1"] = { definition: "security-reviewer" }; });
|
||||
refuses(none, () => veto(none, "nohire", "coder"), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 hire adds and lists a new instance, logs the chief's context, and sets only model and limits", () => {
|
||||
const f = fixture();
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
assert.deepEqual(done.instances["sec-2"], {
|
||||
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false,
|
||||
});
|
||||
assert.equal(done.restart, false);
|
||||
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "chief");
|
||||
assert.deepEqual(entry.context, CONTEXT);
|
||||
assert.equal(entry.transaction, done.transaction);
|
||||
assert.equal(f.validate().status, 0);
|
||||
for (const vars of [{ harness: "pi" }, { model: "claude-sonnet-5-5", thinking: "high" }, []]) {
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars }), "invalid-request");
|
||||
}
|
||||
for (const args of [{ name: "Bad Name", definition: "security-reviewer" }, { name: "human", definition: "security-reviewer" },
|
||||
{ name: "tester", definition: "security-reviewer" }, { name: "sec-3", definition: "Bad Name" }]) {
|
||||
refuses(f, () => hire(f, args), "invalid-request");
|
||||
}
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars: { model: "not-in-the-table" } }), "BusinessError");
|
||||
refuses(f, () => hire(f, { name: "coder-2", definition: "coder" }), "organization-credentials-out-of-scope");
|
||||
refuses(f, () => hire(f, { name: "asst-2", definition: "assistant" }), "organization-definition-refused");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "no-such-role" }), "BusinessError");
|
||||
});
|
||||
|
||||
test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fired one only as it was", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.launch.noHire = ["merge-gate"];
|
||||
doc.roles["sec-2"] = { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.keep = ["sec-3"];
|
||||
});
|
||||
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
|
||||
// Row 213 (M10): a creative definition is the human's to add, never the chief's to hire.
|
||||
for (const definition of CREATIVE_DEFINITIONS) refuses(f, () => hire(f, { name: "muse-1", definition }), "definition-not-hireable");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
|
||||
// sec-2 is declared and not listed: fired. Only its own definition with no vars lists it again.
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "definition-not-hireable");
|
||||
veto(f, "hire", "merge-gate");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-opus-5-5" } }), "organization-instance-exists");
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer" });
|
||||
assert.equal(done.instances["sec-2"].launched, true);
|
||||
assert.deepEqual(f.onDisk().roles["sec-2"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
const none = fixture(doc => { delete doc.launch; });
|
||||
refuses(none, () => hire(none, { name: "sec-9", definition: "security-reviewer" }), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and each refusal holds", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.instances.push("sec-3");
|
||||
doc.launch.keep = ["sec-3"];
|
||||
});
|
||||
const done = fire(f, "sec-1");
|
||||
assert.equal(done.delisted, true);
|
||||
assert.equal(done.instances["sec-1"].launched, false);
|
||||
assert.deepEqual(f.onDisk().roles["sec-1"], { definition: "security-reviewer" });
|
||||
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-3"]);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "chief");
|
||||
assert.deepEqual(entry.context, CONTEXT);
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
const again = fire(f, "sec-1");
|
||||
assert.equal(again.delisted, false);
|
||||
assert.equal(again.transaction, null);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
refuses(f, () => fire(f, "ghost"), "organization-unknown-instance");
|
||||
refuses(f, () => fire(f, "Bad Name"), "organization-unknown-instance");
|
||||
refuses(f, () => fire(f, "sec-3"), "instance-kept");
|
||||
refuses(f, () => fire(f, "assistant"), "organization-definition-refused");
|
||||
refuses(f, () => fire(f, "chief"), "organization-instance-referenced");
|
||||
assert.equal(f.validate().status, 0);
|
||||
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
|
||||
refuses(one, () => fire(one, "sec-1"), "BusinessError");
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
|
||||
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs";
|
||||
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
@@ -74,7 +75,13 @@ test("shipped authority follows the note's table", () => {
|
||||
for (const name of others.map((f) => f.slice(0, -5))) {
|
||||
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
|
||||
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
|
||||
// Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too.
|
||||
for (const action of ["role.hire", "role.fire"]) {
|
||||
assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`);
|
||||
assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`);
|
||||
}
|
||||
}
|
||||
assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire"));
|
||||
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
|
||||
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
|
||||
});
|
||||
@@ -255,3 +262,68 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
|
||||
refuses(creative("companion", { roleVersion: 1, title: undefined, contract: undefined, authority: undefined, credentials: undefined, messaging: undefined }), /^creative role companion must be version 2$/);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.3, T8c): the chief's hireable list, checked at load against HIRE_ALLOWED.
|
||||
// Each refused action is looped from the lists, not typed by hand.
|
||||
const REFUSED_FOR_HIRE = ACTIONS.filter((action) => !HIRE_ALLOWED.includes(action) && !GATED_ONLY.includes(action));
|
||||
|
||||
function hireDir(definitions) {
|
||||
const dir = tmp();
|
||||
for (const [name, doc] of Object.entries(definitions)) {
|
||||
writeFileSync(join(dir, `${name}.md`), "# contract\n");
|
||||
writeJson(join(dir, `${name}.json`), doc === 1 ? { roleVersion: 1, name, tools: ["read"] } : {
|
||||
roleVersion: 2, name, title: name, contract: `${name}.md`, tools: ["read"], network: "none",
|
||||
authority: { withinRole: doc.withinRole ?? ["message.send"], crossRole: doc.crossRole ?? [] }, credentials: [],
|
||||
...(doc.hireable === undefined ? {} : { hireable: doc.hireable }),
|
||||
});
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("hireable: HIRE_ALLOWED is exactly the six plain work actions, and every one is routed", () => {
|
||||
assert.deepEqual([...HIRE_ALLOWED], ["message.send", "record.write", "review.request", "task.update.assigned",
|
||||
"task.scope.change", "git.push.working"]);
|
||||
assert.ok(Object.isFrozen(HIRE_ALLOWED));
|
||||
for (const action of HIRE_ALLOWED) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
assert.ok(!HIRE_ALLOWED.includes("git.push.remote"));
|
||||
assert.ok(REFUSED_FOR_HIRE.length >= 15, "the loop covers the routed actions outside the list");
|
||||
});
|
||||
|
||||
test("hireable: a list naming a definition that holds any refused action refuses at load, one case per action", () => {
|
||||
for (const action of REFUSED_FOR_HIRE) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
|
||||
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
|
||||
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
|
||||
const clean = hireDir({
|
||||
chief: { hireable: ["worker", "helper"] },
|
||||
worker: { withinRole: [...HIRE_ALLOWED] },
|
||||
// helper's own list names a definition that doesn't exist. Only the chief's list is checked.
|
||||
helper: { withinRole: ["record.write"], hireable: ["nobody"] },
|
||||
});
|
||||
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
|
||||
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
|
||||
});
|
||||
|
||||
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
|
||||
// Row 213 (M10): a creative definition refuses by name, before its file is read.
|
||||
for (const name of CREATIVE_DEFINITIONS) {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`));
|
||||
}
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/);
|
||||
assert.deepEqual(loadRole(hireDir({ chief: {} }), "chief").hireable, []);
|
||||
assert.deepEqual(loadRole(REPO_ROLES, "chief-of-staff").hireable, []);
|
||||
// A version 1 file can't carry the key.
|
||||
refuses(() => validateRoleDocument({ roleVersion: 1, name: "r", tools: ["read"], hireable: [] }, join(tmp(), "r.json")), /unsupported role key/);
|
||||
});
|
||||
|
||||
+11
-29
@@ -1061,35 +1061,6 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
|
||||
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
|
||||
command. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
|
||||
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
|
||||
write runs the file's checks on the new document first, bumps the revision by one, and renames
|
||||
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
|
||||
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
|
||||
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
|
||||
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
|
||||
`<dataRoot>/state/access-log.jsonl`.
|
||||
|
||||
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
|
||||
writable by group or other. If it is not, or a business file fails to load, the command refuses
|
||||
with exit 3, names the file and the key path, and writes nothing.
|
||||
|
||||
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
|
||||
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
|
||||
once. Removing a provider from `console.sso` takes effect only after a Console restart.
|
||||
|
||||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
@@ -1107,3 +1078,14 @@ The helper supplies the fixed logical origin, reports status/text/body as JSON,
|
||||
and never retries an uncertain write. It cannot navigate a browser or upgrade the
|
||||
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
|
||||
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
|
||||
|
||||
## Instance processing switch
|
||||
|
||||
`mosaic halt [--business ID]` and `mosaic resume [--business ID]` use the human
|
||||
management path even when the CLI channel is disabled. The business chooses
|
||||
proof context; the affected scope is every managed workload in the resolved
|
||||
data root. Read `mosaic processing status --json` after an unknown or partial
|
||||
result. Resume refuses unconfirmed stops and a stale generation. A restarted
|
||||
`bus start --pm` keeps its control bus up while halted and skips the chief.
|
||||
The switch does not clear other launch/Discord brakes or retry tasks. Native
|
||||
diagnostic launch stays available. See packages/processing-control/README.md.
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// The human edits <configDir>/access.json here. Each write is checked against the
|
||||
// instance's context before it lands, bumps the revision by one, and appends one
|
||||
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
||||
// through humanTransport: a bus that does not see a human shell refuses it, so
|
||||
// no agent and no tester can write. The context (providers, peers, businesses,
|
||||
// ssoSubjects) comes in as a function; the caller decides where it comes from.
|
||||
|
||||
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { refuseInsideAgent } from "./transport.mjs";
|
||||
|
||||
export const ACCESS_USAGE = [
|
||||
"mosaic access list --business ID [--json]",
|
||||
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
||||
].join("\n");
|
||||
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
// runs with the owner's role, so a collaborator drives it with that authority.
|
||||
// Removing a user acts at once. Removing a provider from console.sso waits for a
|
||||
// Console restart, so take the users out of access.json first. Peer homes are
|
||||
// empty until the federation key lands (row 203), so a grant naming a peer
|
||||
// home refuses until then. A grant naming a subject needs console.sso.
|
||||
export const ACCESS_HELP = `${ACCESS_USAGE}
|
||||
|
||||
A grant gives a user exactly what the granted instances can do. The instances
|
||||
list is the real limit: a collaborator's message drives a granted instance with
|
||||
the owner's role.
|
||||
To cut a user off, revoke them here first. It acts at once. Removing a provider
|
||||
from console.sso takes effect only after a Console restart, so do that second.
|
||||
Peer homes are not read yet (row 203 adds the federation key), so a grant that
|
||||
names a peer home refuses. A grant that names a sign-in subject needs console.sso
|
||||
in the system config; without it every such grant refuses.
|
||||
The command runs as the instance's uid: access.json must belong to it and must
|
||||
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
|
||||
|
||||
// The instance's context for checkAccess, read from the system config and the business
|
||||
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
|
||||
// has an id and a subjects list). peers is empty until row 203. A business file that
|
||||
// fails to load fails the whole command.
|
||||
export function accessContextFor(system, env) {
|
||||
const providers = system.console?.sso?.providers ?? [];
|
||||
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
|
||||
const dir = join(configDir(env), "businesses");
|
||||
const businesses = {};
|
||||
if (existsSync(dir)) {
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
||||
const id = file.slice(0, -".json".length);
|
||||
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
||||
}
|
||||
}
|
||||
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
|
||||
}
|
||||
|
||||
// Every verb needs --business: the human proof runs through that business's bus.
|
||||
// --tester is refused outright on every verb, before any bus call.
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--json" || a === "--tester") out.flags.add(a);
|
||||
else if (a.startsWith("--")) {
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
|
||||
out.values[a] = args[++i];
|
||||
} else out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
|
||||
|
||||
function readCurrent(file, context) {
|
||||
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
|
||||
const document = loadAccess(file, context);
|
||||
return { document, revision: document.revision };
|
||||
}
|
||||
|
||||
// The file calls the write path makes. A test passes a stand-in through deps.fs.
|
||||
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
|
||||
|
||||
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
|
||||
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
|
||||
// before or at the rename removes the temp file and leaves the old bytes in place.
|
||||
function writeAtomic(file, text, fs = realFs) {
|
||||
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
|
||||
try {
|
||||
const fd = fs.openSync(tmp, "wx", 0o600);
|
||||
try {
|
||||
fs.writeSync(fd, text);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
// Already gone: the temp file was never created.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function receipt(dataRoot, line) {
|
||||
const dir = join(dataRoot, "state");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Two writers never interleave: the read, the check and the rename happen under an
|
||||
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
|
||||
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
|
||||
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
|
||||
// removes it once no write is running. Fail closed, with no reclaim race.
|
||||
const BUSY = "refused: the access file is being written by another command; try again";
|
||||
|
||||
function lockPid(lock) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(lock, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pid = Number(text.trim());
|
||||
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
|
||||
}
|
||||
|
||||
function pidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
function lockRefusal(lock) {
|
||||
const pid = lockPid(lock);
|
||||
if (pid !== null && pidAlive(pid)) return BUSY;
|
||||
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
}
|
||||
|
||||
function takeLock(lock, fs) {
|
||||
try {
|
||||
return fs.openSync(lock, "wx", 0o600);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
throw new CliError(lockRefusal(lock), 3);
|
||||
}
|
||||
|
||||
export function withLock(file, fn, fs = realFs) {
|
||||
const lock = `${file}.lock`;
|
||||
const fd = takeLock(lock, fs);
|
||||
try {
|
||||
fs.writeSync(fd, `${process.pid}\n`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.unlinkSync(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The one human-proof call: project.list, which the bus gates with #human
|
||||
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
|
||||
// project names. Row R4 swaps this call for access.reload.
|
||||
async function proveHuman(transport, business) {
|
||||
return transport(business)("project.list", {});
|
||||
}
|
||||
|
||||
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
|
||||
// name looked up in project.list at grant time (the id is what gets written).
|
||||
function resolveProjects(items, rows) {
|
||||
return items.map((item) => {
|
||||
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
|
||||
const hits = rows.filter((r) => r.name === item);
|
||||
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
|
||||
return hits[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
|
||||
export async function accessCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
return await accessVerb(args, io, deps);
|
||||
} catch (error) {
|
||||
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
|
||||
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
|
||||
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function accessVerb(args, io, deps) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
|
||||
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
||||
const business = parsed.values["--business"];
|
||||
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const rows = await proveHuman(deps.transport, business);
|
||||
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
|
||||
// Built after the human proof, so a refused caller never reads the business files.
|
||||
const context = deps.contextFor();
|
||||
|
||||
if (sub === "list") {
|
||||
const { document } = readCurrent(file, context);
|
||||
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = parsed.values["--revision"];
|
||||
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
||||
const user = parsed.values["--user"];
|
||||
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const result = withLock(file, () => {
|
||||
const { document, revision } = readCurrent(file, context);
|
||||
if (Number(expected) !== revision) {
|
||||
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
||||
}
|
||||
let next;
|
||||
let touched;
|
||||
if (sub === "grant") {
|
||||
const level = parsed.values["--level"];
|
||||
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
|
||||
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
|
||||
}
|
||||
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
|
||||
const id = `g-${randomBytes(4).toString("hex")}`;
|
||||
const grant = {
|
||||
id,
|
||||
user,
|
||||
business,
|
||||
projects: resolveProjects(list(parsed.values["--projects"]), rows),
|
||||
instances: list(parsed.values["--instances"]),
|
||||
level,
|
||||
};
|
||||
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
|
||||
touched = [id];
|
||||
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
|
||||
} else {
|
||||
const only = parsed.values["--grant"];
|
||||
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
|
||||
touched = document.grants.filter(drop).map((g) => g.id);
|
||||
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
|
||||
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
|
||||
}
|
||||
// Every check runs on the new document before a byte is written.
|
||||
const checked = checkAccess(next, context);
|
||||
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
||||
return { checked, revision, touched };
|
||||
}, deps.fs);
|
||||
|
||||
receipt(deps.system.dataRoot, {
|
||||
at: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
verb: sub,
|
||||
business,
|
||||
user,
|
||||
actor: "human",
|
||||
revisionBefore: result.revision,
|
||||
revisionAfter: result.checked.revision,
|
||||
grants: result.touched,
|
||||
});
|
||||
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
function formatList(doc) {
|
||||
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
||||
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
||||
for (const g of doc.grants) {
|
||||
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
+42
-13
@@ -20,7 +20,6 @@ import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -59,6 +58,9 @@ export const USAGE = `usage:
|
||||
${THREAD_TALK_USAGE}
|
||||
mosaic name <instance> <name> [--business <id>] [--tester]
|
||||
mosaic names [--business <id>] [--json] [--tester]
|
||||
mosaic halt [--business <id>]
|
||||
mosaic resume [--business <id>]
|
||||
mosaic processing status [--business <id>] [--json]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -458,6 +460,36 @@ async function stop(rest, io, deps) {
|
||||
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
|
||||
}
|
||||
|
||||
async function processingCommand(verb, rest, io, deps) {
|
||||
const parsed = parse(rest, { flags: ["--json"], values: ["--business"] });
|
||||
if (verb === 'processing' ? parsed.positional.length !== 1 || parsed.positional[0] !== 'status' : parsed.positional.length !== 0)
|
||||
throw usage('use mosaic halt, mosaic resume or mosaic processing status');
|
||||
refuseInsideAgent(io.env);
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
const call = callFor(parsed, io, { ...deps, system });
|
||||
try {
|
||||
let result;
|
||||
if (verb === 'resume') {
|
||||
const current = await call('processing.status');
|
||||
if (current?.invalid) throw new CliError(`processing state is invalid; use mosaic halt, then mosaic resume (${current.code})`, 3);
|
||||
result = await call('processing.resume', { generation: current?.generation });
|
||||
} else result = await call(verb === 'halt' ? 'processing.halt' : 'processing.status', {});
|
||||
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
|
||||
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
if (verb === 'halt' && result.quiescent !== true)
|
||||
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
|
||||
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
|
||||
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
|
||||
if (result.invalid) io.stdout.write('state is invalid; use mosaic halt, then mosaic resume\n');
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const code = typeof e.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
||||
if (code === 'outcome-unknown') throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
throw new CliError(`processing refused: ${code}`, 3);
|
||||
}
|
||||
}
|
||||
|
||||
async function launches(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "off" || sub === "on") {
|
||||
@@ -512,8 +544,13 @@ async function bus(rest, io, deps) {
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
try {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'processing-halted') throw e;
|
||||
io.stdout.write('processing halted: chief launch skipped; control bus remains available\n');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
await host.close(1);
|
||||
@@ -578,15 +615,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "access") {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
return accessCommand(rest, io, {
|
||||
system,
|
||||
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
|
||||
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
@@ -594,12 +622,13 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
if (verb === "name" || verb === "names") return names(verb, rest, io, deps);
|
||||
if (verb === "stop") return stop(rest, io, deps);
|
||||
if (["halt", "resume", "processing"].includes(verb)) return processingCommand(verb, rest, io, deps);
|
||||
if (verb === "launches") return launches(rest, io, deps);
|
||||
if (verb === "bus") return bus(rest, io, deps);
|
||||
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
|
||||
|
||||
@@ -85,6 +85,7 @@ import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
const HEADER_TIMEOUT_MS = 10000;
|
||||
@@ -372,7 +373,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
const p = project(facts.project);
|
||||
const where = { dataRoot, business: b.id, project: facts.project, env };
|
||||
const refused = async (e, notify) => {
|
||||
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
|
||||
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError || e.name === 'ProcessingError' ? e.code : "git-failed";
|
||||
if (code === "git-failed" || code === "rollback-failed") log(`git socket: ${id}: ${code}: ${e.message}`);
|
||||
// The host's own faults stay in its log; the role gets the code.
|
||||
const detail = notify && code !== "git-failed" && e.message !== code ? e.message.slice(0, 1000) : null;
|
||||
@@ -392,20 +393,19 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
}
|
||||
const { commit, base, reviewed, patch_id } = prepared;
|
||||
socket.end(`${JSON.stringify({ ok: true, result: { landing: "started", commit, base, reviewed, patch_id } })}\n`);
|
||||
slot.done = (async () => {
|
||||
slot.done = withProcessing(dataRoot, async scope => {
|
||||
try {
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: slot.abort.signal });
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
|
||||
scope.guard();
|
||||
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
|
||||
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
|
||||
} catch (e) {
|
||||
await refused(e, true);
|
||||
} finally {
|
||||
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
await gate.dropNetwork(`${id}_default`, gateEnv(env));
|
||||
landing = null;
|
||||
}
|
||||
})();
|
||||
}).catch(e => refused(e, true));
|
||||
}
|
||||
|
||||
async function doPublish(socket, cap, req) {
|
||||
@@ -479,11 +479,17 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
if (typeof req.task_ref === "string") subject = req.task_ref;
|
||||
if (!who.role || who.business !== b.id) throw new Refusal("not-a-role");
|
||||
file = join(dir, `${randomBytes(8).toString("hex")}.bundle`);
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else if (req.op === "land") await doLand(socket, cap, req);
|
||||
else if (req.op === "publish") await doPublish(socket, cap, req);
|
||||
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
|
||||
await withProcessing(dataRoot, async scope => {
|
||||
const abort = () => socket.destroy();
|
||||
scope.signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else if (req.op === "land") await doLand(socket, cap, req);
|
||||
else if (req.op === "publish") await doPublish(socket, cap, req);
|
||||
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
|
||||
} finally { scope.signal.removeEventListener('abort', abort); }
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Recorded) return fail(e.code);
|
||||
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
|
||||
|
||||
+155
-10
@@ -3,17 +3,28 @@
|
||||
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
|
||||
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
|
||||
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
|
||||
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
|
||||
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
|
||||
// instance.request until the request closes or --wait runs out (D1.1 item 5).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] [--bot BOT --bot-id N] --revision REV [--tester] | " +
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
|
||||
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
|
||||
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
|
||||
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
|
||||
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
|
||||
|
||||
// The bus answers a refusal with its code alone, so the reason a human reads is here.
|
||||
export const REASONS = {
|
||||
"organization-credentials-out-of-scope": "this command binds no credentials, so it refuses a definition that declares them " +
|
||||
"and a definition change on an instance that holds them; that stays a hand edit of the business file",
|
||||
"organization-credentials-out-of-scope": "only add binds credentials, as store references; set refuses a definition that " +
|
||||
"declares them and a definition change on an instance that holds them, and delete refuses a file or env reference; " +
|
||||
"those stay a hand edit of the business file",
|
||||
// Row 194 (brief item 7, sage-1610 a): add names an existing Vikunja bot; it makes no account.
|
||||
"organization-tracker-required": "the definition uses vikunja; add needs --bot BOT --bot-id N, an existing Vikunja bot",
|
||||
"organization-tracker-bot-taken": "another instance already uses that Vikunja bot name or id",
|
||||
"organization-instance-exists": "the business already has that instance; change it with `mosaic business instance set`",
|
||||
"organization-unknown-instance": "the business has no such instance; add it with `mosaic business instance add`",
|
||||
"organization-definition-refused": "this command neither adds nor changes an assistant, and moves no instance to the assistant " +
|
||||
@@ -23,8 +34,37 @@ export const REASONS = {
|
||||
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
|
||||
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
|
||||
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
|
||||
// Row 194 (#1692): the lifecycle and veto refusals.
|
||||
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
|
||||
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
|
||||
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
|
||||
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
|
||||
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
|
||||
"instance-retired": "that name was deleted and launches no more",
|
||||
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
|
||||
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
|
||||
"not-held": "the instance has no run and no open request",
|
||||
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
|
||||
"launches-disabled": "the bus host was started with --no-launch",
|
||||
"request-expired": "the launcher didn't take the request within its 60 s",
|
||||
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
|
||||
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
|
||||
"run the same delete again to retry the rename",
|
||||
// Row 194 (brief item 8, deviation 11, sage-1620): a delete revokes before it writes, so a failure can
|
||||
// leave tokens revoked with the instance still listed. It holds nothing it can use, and a retry is safe.
|
||||
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
|
||||
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
|
||||
"command again, which is safe",
|
||||
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"unknown-role": "the business has no such instance",
|
||||
};
|
||||
|
||||
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
|
||||
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const WAIT_SECONDS = 30;
|
||||
const POLL_MS = 200;
|
||||
|
||||
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
|
||||
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
|
||||
|
||||
@@ -35,7 +75,7 @@ function parse(args) {
|
||||
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
|
||||
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
|
||||
out.flags.add(arg);
|
||||
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
|
||||
} else if (["--definition", "--vars", "--revision", "--wait", "--reason", "--bot", "--bot-id"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
|
||||
@@ -48,6 +88,11 @@ function parse(args) {
|
||||
function setArgs(parsed) {
|
||||
const [, op, , instance] = parsed.positional;
|
||||
const { values, flags } = parsed;
|
||||
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
|
||||
const bot = values["--bot"] !== undefined || values["--bot-id"] !== undefined;
|
||||
if (bot && (op !== "add" || values["--bot"] === undefined || !/^[0-9]{1,15}$/.test(values["--bot-id"] ?? ""))) {
|
||||
throw new CliError(INSTANCES_USAGE, 4);
|
||||
}
|
||||
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
|
||||
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
|
||||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
|
||||
@@ -64,13 +109,96 @@ function setArgs(parsed) {
|
||||
}
|
||||
}
|
||||
if (launch !== undefined) args.launch = launch;
|
||||
if (bot) args.tracker = { bot: values["--bot"], botId: Number(values["--bot-id"]) };
|
||||
return args;
|
||||
}
|
||||
|
||||
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
|
||||
function lifecycleArgs(parsed) {
|
||||
const { positional, values, flags } = parsed;
|
||||
const [sub, op, , name] = positional;
|
||||
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
|
||||
let wait = WAIT_SECONDS;
|
||||
if (values["--wait"] !== undefined) {
|
||||
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
|
||||
wait = Number(values["--wait"]);
|
||||
}
|
||||
if (sub === "veto") {
|
||||
const [, , vop, target] = positional;
|
||||
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
|
||||
}
|
||||
if (!LIFECYCLE_OPS.includes(op)) return null;
|
||||
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
|
||||
if (op === "terminate") {
|
||||
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
|
||||
}
|
||||
if (op === "delete") {
|
||||
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
|
||||
}
|
||||
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: `instance.${op}`, args: { instance: name }, wait };
|
||||
}
|
||||
|
||||
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
|
||||
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
|
||||
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
|
||||
// outcome-unknown.
|
||||
async function follow(call, request, wait) {
|
||||
const end = Date.now() + wait * 1000;
|
||||
for (;;) {
|
||||
const r = await call("instance.request", { request });
|
||||
if (r.state === "closed") {
|
||||
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
|
||||
if (r.done?.refusal) {
|
||||
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
|
||||
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if (Date.now() >= end) {
|
||||
const error = r.state === "taken"
|
||||
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
|
||||
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
|
||||
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
|
||||
error.final = true;
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
|
||||
}
|
||||
}
|
||||
|
||||
// `final`: the catch below passes it on as it is, with its request id.
|
||||
function refusal(code, why) {
|
||||
const error = new CliError(`${code}: ${why}`, 3);
|
||||
error.code = code;
|
||||
error.final = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
function lifecycleLine(business, op, instance, r) {
|
||||
if (op === "delete") {
|
||||
const bot = r.done.bot ? `tracker bot ${r.done.bot}: remove the Vikunja bot user and any token it holds by hand in Vikunja\n` : "";
|
||||
return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n${bot}`;
|
||||
}
|
||||
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
|
||||
}
|
||||
|
||||
function terminateLines(business, instance, out) {
|
||||
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
|
||||
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
|
||||
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
|
||||
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function formatInstances(view) {
|
||||
const lines = [`business ${view.business}, revision ${view.revision}`];
|
||||
for (const [name, entry] of Object.entries(view.instances)) {
|
||||
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
|
||||
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
|
||||
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
|
||||
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
|
||||
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
|
||||
}
|
||||
@@ -78,7 +206,9 @@ export function formatInstances(view) {
|
||||
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
|
||||
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
|
||||
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
|
||||
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
|
||||
if (bound.length) lines.push(`definitions that declare credentials, added with store references and a restart: ${bound.join(", ")}`);
|
||||
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
|
||||
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -86,16 +216,30 @@ export function formatInstances(view) {
|
||||
export async function instancesCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
const business = parsed.positional[sub === "instances" ? 1 : 2];
|
||||
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
|
||||
const business = parsed.positional[sub === "instance" ? 2 : 1];
|
||||
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
|
||||
let request;
|
||||
if (sub === "instances") {
|
||||
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
|
||||
if (lifecycle) {
|
||||
// veto and the lifecycle verbs, below.
|
||||
} else if (sub === "instances") {
|
||||
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
|
||||
throw new CliError(INSTANCES_USAGE, 4);
|
||||
}
|
||||
} else request = setArgs(parsed);
|
||||
try {
|
||||
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
|
||||
if (lifecycle) {
|
||||
const out = await call(lifecycle.verb, lifecycle.args);
|
||||
if (sub === "veto") {
|
||||
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as instances.set does.
|
||||
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n${out.restart ? RESTART_NEEDED : NO_RESTART}\n`);
|
||||
}
|
||||
const op = parsed.positional[1];
|
||||
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
|
||||
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
|
||||
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
|
||||
}
|
||||
if (!request) {
|
||||
const view = await call("instances", {});
|
||||
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
|
||||
@@ -109,6 +253,7 @@ export async function instancesCommand(args, io, callFor) {
|
||||
result.restart ? RESTART_NEEDED : NO_RESTART,
|
||||
].join("\n") + "\n");
|
||||
} catch (error) {
|
||||
if (error.final) throw error;
|
||||
const code = error.code;
|
||||
if (Object.hasOwn(REASONS, code)) {
|
||||
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
|
||||
|
||||
+274
-19
@@ -96,10 +96,37 @@
|
||||
//
|
||||
// Nothing here is persisted. After a restart recover() ends the earlier
|
||||
// host's runs, so the first tick starts each holder.
|
||||
//
|
||||
// Row 194 (#1692, D1.1): the lifecycle pass, on the same tick, carries out the
|
||||
// human's and the tester's requests the broker recorded (launch, end,
|
||||
// regenerate, delete). The broker never starts a process or touches a
|
||||
// workspace. Under serialize, each open request is taken (host op
|
||||
// lifecycleTake, which re-checks it and closes it with a code when it may not
|
||||
// run), then acted on, then closed with lifecycleDone. A taken request never
|
||||
// runs again, whatever happens after the take. A done that throws leaves it
|
||||
// taken with no done, which the CLI reports as outcome-unknown.
|
||||
// - launch: check() and start() with `human`, so an unlisted instance runs.
|
||||
// - end: SIGTERM to each named run's runner after the start-time check, and
|
||||
// to any run of the instance in the registry that spawned by the verb's
|
||||
// time (N6, DESIGN.md:118-120). A run spawned after it is the next end's.
|
||||
// The exit handler records the end. No SIGKILL for a terminate.
|
||||
// - a fire's end (kill: true, S6): the same runs, each through row 191's
|
||||
// stopRun with reason fire, which escalates to SIGKILL after the grace.
|
||||
// A run it killed or left alive goes back in lifecycleDone's stops.
|
||||
// - regenerate: SIGTERM to every run of the instance, then a new run once
|
||||
// the old one has left the registry. Not gone by the request's 60 s mark
|
||||
// closes it regenerate-timeout and starts nothing.
|
||||
// While row 191's switch is on the tick runs only this pass (D6): a
|
||||
// terminate still ends runs, and the take closes a launch or regenerate
|
||||
// processing-halted.
|
||||
// - delete: instance-running against the registry, then lifecycleDelete
|
||||
// (the writer's commit), then the rename of workspaces/<b>/I/ to
|
||||
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
|
||||
// workspace-rename-failed with the transaction; a later delete retries it.
|
||||
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
@@ -127,6 +154,9 @@ import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, runtime, unregisterRuntime, processIdentity, stopRun } from "../../processing-control/src/runtimes.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
@@ -134,6 +164,8 @@ const LINE_MAX = 4096;
|
||||
const THREAD_TICK_MS = 2000;
|
||||
// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
// A refusal the host decided; `code` goes back to the caller and to the broker.
|
||||
class Refusal extends Error {
|
||||
@@ -155,13 +187,15 @@ function call(name, what, fn) {
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// rehearsal): start() refuses launches-disabled, the ticker runs only the
|
||||
// lifecycle pass (row 194), which closes each request launches-disabled, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
// stopTimeoutMs, threadTickMs, lifecycleRequestMs, git (createGitSocket
|
||||
// options) and stopOptions (row 191's stopRun options, for a fire's stop)
|
||||
// exist for the tests; the command line never sets them.
|
||||
export function createLauncher({
|
||||
host,
|
||||
system,
|
||||
@@ -175,10 +209,19 @@ export function createLauncher({
|
||||
harnessVersion = undefined,
|
||||
stopTimeoutMs = STOP_TIMEOUT_MS,
|
||||
threadTickMs = THREAD_TICK_MS,
|
||||
lifecycleRequestMs = LIFECYCLE_REQUEST_MS,
|
||||
git = {},
|
||||
stopOptions = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
}) {
|
||||
const dataRoot = system.dataRoot;
|
||||
function admission(generation = null) {
|
||||
try { return assertProcessing(dataRoot, generation); }
|
||||
catch (e) {
|
||||
if (e instanceof ProcessingError) throw new Refusal(e.code);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
@@ -203,6 +246,8 @@ export function createLauncher({
|
||||
const stopping = new Set();
|
||||
const refusedThreads = new Map();
|
||||
const startedFor = new Map();
|
||||
// Row 194 N6: run -> when its process was spawned, so an end signals only the runs spawned by its verb.
|
||||
const spawnedAt = new Map();
|
||||
// Row 156: run -> the decisions its landing names; decision -> started; decision -> last
|
||||
// refusal code; run -> last landRelease refusal code; and landHolders' last refusal.
|
||||
const landingOf = new Map();
|
||||
@@ -210,6 +255,7 @@ export function createLauncher({
|
||||
const refusedLand = new Map();
|
||||
const refusedRelease = new Map();
|
||||
let refusedHolders = null;
|
||||
let refusedRequests = null;
|
||||
const sockets = new Set();
|
||||
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
|
||||
|
||||
@@ -229,11 +275,14 @@ export function createLauncher({
|
||||
|
||||
// Steps 2 to 9. Returns what the launch needs. Only `--pm` may name
|
||||
// launch.by itself. A thread instance launches only for a thread, and
|
||||
// nothing else launches for one.
|
||||
async function check(instance, { pm = false, thread = null } = {}) {
|
||||
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
|
||||
// human's launch request, which may name any declared instance. The listing
|
||||
// limits the chief, not the human. launch.by still takes the `pm` branch.
|
||||
async function check(instance, { pm = false, thread = null, human = false } = {}) {
|
||||
const admissionGeneration = admission().generation;
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -324,11 +373,13 @@ export function createLauncher({
|
||||
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
|
||||
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
|
||||
}
|
||||
return { resolved, model, account, harness, route, routeReason };
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration };
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false, admissionGeneration }) {
|
||||
admission(admissionGeneration);
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
@@ -342,6 +393,7 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
|
||||
admission(admissionGeneration);
|
||||
// The clones come before the run, so one that can't be made leaves no
|
||||
// run behind. The host's git treats each clone as hostile, and its
|
||||
// checks hold only while no session writes the workspace: a workspace
|
||||
@@ -351,7 +403,10 @@ export function createLauncher({
|
||||
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
||||
let checkouts;
|
||||
try {
|
||||
checkouts = await ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
|
||||
checkouts = await withProcessing(dataRoot, () => {
|
||||
admission(admissionGeneration);
|
||||
return ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
|
||||
throw e;
|
||||
@@ -361,7 +416,8 @@ export function createLauncher({
|
||||
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
|
||||
}
|
||||
let harness, route, routeReason;
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
|
||||
admission(admissionGeneration);
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
@@ -436,7 +492,18 @@ export function createLauncher({
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
};
|
||||
const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
|
||||
admission(admissionGeneration);
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
try {
|
||||
admission(admissionGeneration);
|
||||
spawnedAt.set(run, Date.now());
|
||||
child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
|
||||
} catch (e) {
|
||||
spawnedAt.delete(run);
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
throw e;
|
||||
}
|
||||
let bound = false;
|
||||
let entry = null;
|
||||
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve(sessionExit(code, signal))));
|
||||
@@ -445,10 +512,16 @@ export function createLauncher({
|
||||
try {
|
||||
if (child.pid === undefined) throw new Error("the session process did not start");
|
||||
const outerStart = startTimeOf(child.pid);
|
||||
const identity = processIdentity(child.pid);
|
||||
if (!identity) throw new Error("the session process exited at once");
|
||||
updateRuntime(dataRoot, managed.id, { ...identity, group: true, phase: 'running' });
|
||||
const runner = await runnerOf(child);
|
||||
updateRuntime(dataRoot, managed.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
|
||||
admission(admissionGeneration);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
bound = true;
|
||||
admission(admissionGeneration);
|
||||
entry = {
|
||||
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
||||
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
|
||||
@@ -462,22 +535,33 @@ export function createLauncher({
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
if (bound) await endRun(run, "failed", null);
|
||||
spawnedAt.delete(run);
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
if (e instanceof Refusal) throw e;
|
||||
throw new Refusal("launch-failed", e.message);
|
||||
}
|
||||
children.set(run, exited);
|
||||
exited.then(async ({ code, signal }) => {
|
||||
const reason = endReason(code, signal);
|
||||
let receipt;
|
||||
try { receipt = runtime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt unavailable at exit`); }
|
||||
const state = processingStatus(dataRoot);
|
||||
const haltGeneration = receipt?.haltGeneration ?? (state.halted && !state.invalid ? state.generation : null);
|
||||
const reason = haltGeneration ? 'kill-switch' : endReason(code, signal);
|
||||
try {
|
||||
const ended = await endRun(run, reason, signal ? null : code);
|
||||
registry.remove(b.id, run);
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row.
|
||||
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(ended?.limit ? { limit: ended.limit } : {}) });
|
||||
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
|
||||
log(`run ${run} (${instance}) ended: ${reason}`);
|
||||
} catch (e) {
|
||||
log(`run ${run}: ${e.message}`);
|
||||
} finally {
|
||||
try { unregisterRuntime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt cleanup refused`); }
|
||||
children.delete(run);
|
||||
stopping.delete(run);
|
||||
spawnedAt.delete(run);
|
||||
landingOf.delete(run);
|
||||
refusedRelease.delete(run);
|
||||
}
|
||||
@@ -488,12 +572,19 @@ export function createLauncher({
|
||||
|
||||
// Row 121: one tick of thread starts and idle stops, then (row 156) the
|
||||
// landing pass. A tick that is still running when the next is due skips it.
|
||||
// Row 194 (D6): while row 191's switch is on, only the lifecycle pass runs.
|
||||
async function tick() {
|
||||
if (ticking || closed) return;
|
||||
let halted = false;
|
||||
try { admission(); } catch { halted = true; }
|
||||
ticking = true;
|
||||
try {
|
||||
await threadPass();
|
||||
await landPass();
|
||||
// A --no-launch host runs only the lifecycle pass, which closes each request launches-disabled.
|
||||
if (launches && !halted) {
|
||||
await threadPass();
|
||||
await landPass();
|
||||
}
|
||||
await lifecyclePass();
|
||||
} finally {
|
||||
ticking = false;
|
||||
}
|
||||
@@ -613,6 +704,168 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D1.1): the human's and the tester's lifecycle requests, oldest first.
|
||||
async function lifecyclePass() {
|
||||
let requests;
|
||||
try {
|
||||
requests = await host.op({ op: "lifecycleRequests", business: b.id });
|
||||
if (!Array.isArray(requests)) throw new Refusal("malformed-answer");
|
||||
refusedRequests = null;
|
||||
} catch (e) {
|
||||
const code = e.code ?? e.message;
|
||||
if (refusedRequests !== code) log(`lifecycleRequests refused: ${code}`);
|
||||
refusedRequests = code;
|
||||
return;
|
||||
}
|
||||
for (const r of requests) {
|
||||
if (closed) return;
|
||||
// A taken request is past this pass: it ran, or its done failed.
|
||||
if (r.state !== "open") continue;
|
||||
await serialize(() => carry(r));
|
||||
}
|
||||
}
|
||||
|
||||
// One request: the take, the act, the done. Runs under serialize, so no start() runs alongside it.
|
||||
async function carry(r) {
|
||||
if (closed) return;
|
||||
let take;
|
||||
try {
|
||||
take = await host.op({ op: "lifecycleTake", record: { business: b.id, request: r.request } });
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): lifecycleTake refused: ${e.code ?? e.message}`);
|
||||
return;
|
||||
}
|
||||
if (take?.go !== true) {
|
||||
if (take?.refusal && take.refusal !== "request-closed") record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: take.refusal });
|
||||
return;
|
||||
}
|
||||
let done;
|
||||
try {
|
||||
done = await act(take);
|
||||
} catch (e) {
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
|
||||
}
|
||||
const { why, limit, ...closing } = done;
|
||||
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
|
||||
try {
|
||||
await host.op({ op: "lifecycleDone", record: { business: b.id, request: r.request, ...closing } });
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): lifecycleDone refused: ${e.code ?? e.message}${closing.run ? `; run ${closing.run} started` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// What the request did: { run } for a start, { transaction } for a delete, {} for an end, or { refusal }.
|
||||
async function act(r) {
|
||||
// Row 163: a --no-launch host starts, signals and renames nothing.
|
||||
if (!launches) return { refusal: "launches-disabled" };
|
||||
const launchedBy = r.by === "tester" ? "tester" : b.human;
|
||||
if (r.action === "lifecycle.launch") return launch(r.instance, launchedBy);
|
||||
if (r.action === "lifecycle.end") {
|
||||
const ends = ending(r);
|
||||
if (r.kill !== true) {
|
||||
terminate(ends);
|
||||
return {};
|
||||
}
|
||||
return { stops: await fireStop(r, ends) };
|
||||
}
|
||||
if (r.action === "lifecycle.regenerate") {
|
||||
terminate(registry.sessions.filter((s) => s.business === b.id && s.instance === r.instance));
|
||||
const deadline = Date.parse(r.at) + lifecycleRequestMs;
|
||||
while (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) {
|
||||
if (closed) return { refusal: "host-closing" };
|
||||
if (Date.now() >= deadline) return { refusal: "regenerate-timeout" };
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return launch(r.instance, launchedBy);
|
||||
}
|
||||
if (r.action === "lifecycle.delete") return remove(r);
|
||||
return { refusal: "invalid-request" };
|
||||
}
|
||||
|
||||
async function launch(instance, launchedBy) {
|
||||
const pm = instance === b.launch?.by;
|
||||
const { run } = await start({ instance, launchedBy, human: true, ...(await check(instance, { pm, human: true })) });
|
||||
return { run };
|
||||
}
|
||||
|
||||
// The registry runs an end stops (N6, DESIGN.md:118-120): the runs its verb named, and the runs of
|
||||
// the instance spawned by the verb's time that the verb didn't name, which hadn't bound then. A
|
||||
// run spawned after the verb is the next end's. A thread instance's end names only the runs it
|
||||
// revoked (the tester's own). A run this host didn't spawn counts from its registry start.
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
|
||||
// SIGTERM, after the start-time check, to each run. No SIGKILL: only a fire escalates.
|
||||
function terminate(sessions) {
|
||||
for (const s of sessions) {
|
||||
stopping.add(s.run);
|
||||
record({ event: "lifecycle-end", instance: s.instance, run: s.run });
|
||||
try {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// D2.2 (S6): a fire's end stops each run through row 191's stopRun, owned by the end request (fire
|
||||
// takes no decision), so a halt sweep that meets the run joins this stop. stopRun checks the
|
||||
// identity (start ticks and boot id) before each signal and escalates to SIGKILL after its
|
||||
// grace. The runs it killed or left alive go back to the broker as lifecycle.end.killed and
|
||||
// lifecycle.end.alive.
|
||||
async function fireStop(r, sessions) {
|
||||
const results = await Promise.all(sessions.map(async (s) => {
|
||||
stopping.add(s.run);
|
||||
record({ event: "lifecycle-end", instance: s.instance, run: s.run, kill: true });
|
||||
let out;
|
||||
try {
|
||||
out = await stopRun({ root: dataRoot, business: b.id, run: s.run, reason: "fire", owner: { decision: r.request } }, stopOptions);
|
||||
} catch (e) {
|
||||
log(`run ${s.run}: fire stop failed: ${e.message}`);
|
||||
out = { outcome: "unknown", pid: null, signals: [] };
|
||||
}
|
||||
record({ event: "fire-stop", instance: s.instance, run: s.run, request: r.request, outcome: out.outcome, pid: out.pid, signals: out.signals });
|
||||
return { run: s.run, pid: out.pid, outcome: out.outcome };
|
||||
}));
|
||||
// sage-2289: a stop that can't confirm the kill (unknown) reads as alive with reason unknown,
|
||||
// never as killed, so the Console flags it.
|
||||
return results.flatMap((x) => x.outcome === "unknown" ? [{ ...x, outcome: "alive", reason: "unknown" }]
|
||||
: x.outcome === "killed" || x.outcome === "alive" ? [x] : []);
|
||||
}
|
||||
|
||||
// V5 steps 1 to 3. The file goes first: once committed, check() refuses the name, so no launch
|
||||
// opens the old workspace even if the rename fails.
|
||||
async function remove(r) {
|
||||
if (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) return { refusal: "instance-running" };
|
||||
let out;
|
||||
try {
|
||||
out = await host.op({ op: "lifecycleDelete", record: { business: b.id, request: r.request } });
|
||||
} catch (e) {
|
||||
return { refusal: e.code ?? "outcome-unknown" };
|
||||
}
|
||||
const transaction = out?.transaction ?? null;
|
||||
if (transaction === null) return { refusal: "workspace-rename-failed" };
|
||||
const bot = out.bot ? { bot: out.bot } : {};
|
||||
const from = workspaceDir(dataRoot, b.id, r.instance);
|
||||
if (!existsSync(from)) return { transaction, ...bot };
|
||||
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
|
||||
try {
|
||||
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(to), 0o700);
|
||||
if (existsSync(to)) throw new Error(`${to} exists`);
|
||||
renameSync(from, to);
|
||||
chmodSync(to, 0o700);
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
|
||||
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
|
||||
}
|
||||
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
|
||||
return { transaction, ...bot };
|
||||
}
|
||||
|
||||
// endLaunch's answer ({ released, limit? }), or null when it refused.
|
||||
async function endRun(run, reason, exitCode) {
|
||||
try {
|
||||
@@ -679,7 +932,9 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -766,7 +1021,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { openDirect } from "../../discord/src/notify.mjs";
|
||||
import { createNotifier, runLoop } from "./notifier.mjs";
|
||||
import { withProcessing, currentProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const log = (line) => process.stderr.write(`mosaic-notify: ${line}\n`);
|
||||
let loop = null;
|
||||
@@ -37,8 +38,13 @@ if (!process.send) {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const client = new Client({ path: m.path, cap: m.cap });
|
||||
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}), log });
|
||||
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, log });
|
||||
const processing = { work: fn => withProcessing(m.dataRoot, fn), guard: () => currentProcessing()?.guard() };
|
||||
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}),
|
||||
fetch: (url, init) => {
|
||||
const scope = currentProcessing(); scope?.guard();
|
||||
return fetch(url, { ...init, ...(scope ? { signal: scope.signal } : {}) });
|
||||
}, log });
|
||||
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, processing, log });
|
||||
loop = runLoop(notifier, { pollMs: m.pollMs ?? undefined, log });
|
||||
process.send({ ok: true });
|
||||
} catch (e) {
|
||||
|
||||
@@ -251,7 +251,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
|
||||
// inbox(): the broker's inbox through the reader capability.
|
||||
// direct.send({content, nonce}) resolves {messageId} or throws a RestOutcome.
|
||||
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, log = () => {} }) {
|
||||
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, processing = null, log = () => {} }) {
|
||||
zoned(now(), zone);
|
||||
const journal = openJournal(journalPath(dataRoot, business), { log, now });
|
||||
const backoff = new Map();
|
||||
@@ -269,6 +269,7 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
|
||||
}
|
||||
|
||||
async function attempt(key, record, message) {
|
||||
processing?.guard();
|
||||
const t = now().getTime();
|
||||
try {
|
||||
const { messageId } = await direct.send(message);
|
||||
@@ -328,7 +329,11 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
|
||||
return done;
|
||||
}
|
||||
|
||||
return Object.freeze({ tick, journal: journalPath(dataRoot, business) });
|
||||
const poll = () => processing ? processing.work(tick).catch(e => {
|
||||
if (e.name !== 'ProcessingError') throw e;
|
||||
return { dms: 0, digest: false, failed: 0, halted: true };
|
||||
}) : tick();
|
||||
return Object.freeze({ tick: poll, journal: journalPath(dataRoot, business) });
|
||||
}
|
||||
|
||||
// Runs tick() every pollMs until stop(). Ticks never overlap.
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
|
||||
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
|
||||
// The one test that runs the real human-cli.mjs does it on a scratch bus from
|
||||
// agent ancestry, so the bus must refuse, and nothing is written.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as nodeFs from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { socketPath } from "../src/config.mjs";
|
||||
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
|
||||
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
|
||||
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
|
||||
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: [],
|
||||
businesses: { acme: { human: "jason", instances: ["coder"] } },
|
||||
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
|
||||
};
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
|
||||
// that sees no human shell when FAKE_MODE is "refuse".
|
||||
const BODY = `
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
|
||||
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
|
||||
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
|
||||
`;
|
||||
|
||||
function setup(t, { mode = "ok", doc = DOC } = {}) {
|
||||
const root = tmp(t, "mosaic-access-");
|
||||
const config = join(root, "config");
|
||||
mkdirSync(config, { recursive: true });
|
||||
const dataRoot = join(root, "data");
|
||||
const log = join(root, "fake.log");
|
||||
const cli = fakeCli(root, BODY);
|
||||
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
|
||||
const file = join(config, "access.json");
|
||||
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
||||
const deps = {
|
||||
system: { dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
now: () => new Date("2026-10-20T20:00:00Z"),
|
||||
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
|
||||
};
|
||||
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
|
||||
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
|
||||
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
|
||||
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
|
||||
|
||||
async function run(args, deps, env = humanEnv()) {
|
||||
const i = io(env);
|
||||
await accessCommand(args, i, deps);
|
||||
return i.out.text;
|
||||
}
|
||||
|
||||
async function refusal(promise, pattern) {
|
||||
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
|
||||
return promise.catch((e) => e);
|
||||
}
|
||||
|
||||
test("list prints the revision and the users", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(["list", "--business", "acme"], s.deps);
|
||||
assert.match(out, /access file revision 1/);
|
||||
assert.match(out, /user alice home local/);
|
||||
});
|
||||
|
||||
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run([...GRANT, "--revision", "1"], s.deps);
|
||||
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants.length, 1);
|
||||
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
|
||||
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(line.verb, "grant");
|
||||
assert.equal(line.revisionBefore, 1);
|
||||
assert.equal(line.revisionAfter, 2);
|
||||
assert.equal(line.actor, "human");
|
||||
});
|
||||
|
||||
test("a viewer grant writes the viewer level, one revision up", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "viewer", "--revision", "1"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants[0].level, "viewer");
|
||||
});
|
||||
|
||||
test("a second write appends a second receipt and leaves the first intact", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
|
||||
assert.equal(lines[0].revisionAfter, 2);
|
||||
});
|
||||
|
||||
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts().trim().split("\n").length, 1);
|
||||
});
|
||||
|
||||
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
|
||||
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
|
||||
});
|
||||
|
||||
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, "");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), true);
|
||||
});
|
||||
|
||||
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
|
||||
const s = setup(t);
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
|
||||
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
|
||||
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
|
||||
});
|
||||
|
||||
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
|
||||
const s = setup(t);
|
||||
let mode;
|
||||
withLock(s.file, () => {
|
||||
mode = statSync(`${s.file}.lock`).mode & 0o777;
|
||||
});
|
||||
assert.equal(mode, 0o600);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("a grant naming an unknown instance is refused before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(bad, s.deps), /refused: /);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a project name resolves to its id at grant time", async (t) => {
|
||||
const s = setup(t);
|
||||
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await run(args, s.deps);
|
||||
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
|
||||
});
|
||||
|
||||
test("an unknown project name refuses before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(args, s.deps), /project-not-found/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 3);
|
||||
assert.deepEqual(doc.grants, []);
|
||||
});
|
||||
|
||||
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("an agent caller is refused before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(existsSync(s.log), false);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("--tester is refused on every verb before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
|
||||
const args = [...verb, "--business", "acme", "--tester"];
|
||||
const err = await refusal(run(args, s.deps), /not --tester/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
});
|
||||
|
||||
test("a bus that refuses the human proof blocks the write", async (t) => {
|
||||
const s = setup(t, { mode: "refuse" });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-ctx-");
|
||||
const f = fixture(root, "acme");
|
||||
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
|
||||
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(ctx.providers, ["authentik"]);
|
||||
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
|
||||
assert.deepEqual(ctx.peers, []);
|
||||
assert.equal(ctx.businesses.acme.human, "jason");
|
||||
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
|
||||
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(bare.ssoSubjects, []);
|
||||
assert.deepEqual(bare.providers, []);
|
||||
});
|
||||
|
||||
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
|
||||
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-real-");
|
||||
const f = fixture(root, "acme");
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
writeJson(join(config, "access.json"), DOC);
|
||||
const file = join(config, "access.json");
|
||||
const before = readFileSync(file, "utf8");
|
||||
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
|
||||
};
|
||||
// The bus closes the socket on a failed human proof, so the client reports
|
||||
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
|
||||
// outside R5 (BUILD.md). The test pins it, and nothing is written.
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
|
||||
assert.equal(err.code, "outcome-unknown");
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
|
||||
});
|
||||
@@ -3,10 +3,18 @@ import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
|
||||
const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'];
|
||||
function copyProcessingInputs(root){
|
||||
mkdirSync(join(root,'packages/processing-control/src'),{recursive:true});
|
||||
for(const file of processingInputs)cpSync(new URL('../../../'+file,import.meta.url),join(root,file));
|
||||
}
|
||||
|
||||
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
|
||||
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
|
||||
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
|
||||
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
@@ -25,6 +33,7 @@ test('exclusive image preparation records actual image ID and build wall time an
|
||||
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
|
||||
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
|
||||
@@ -40,6 +49,7 @@ test('cleanup refuses a foreign Docker label and removes only an exact owned pro
|
||||
function imageFixture(t){
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
return {root,receipt};
|
||||
@@ -134,3 +144,12 @@ test('guard owns every Compose file argument and refuses caller configuration ov
|
||||
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
|
||||
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
|
||||
});
|
||||
|
||||
// Row 191 image-input manifest: either copied reader must invalidate the pinned image.
|
||||
for(const file of processingInputs)test(`changing ${file} invalidates the image receipt`,t=>{
|
||||
const {root,receipt}=imageFixture(t);
|
||||
const inspect=()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'});
|
||||
assert.equal(image.verifyImage(receipt,root,inspect).imageId,receipt.imageId);
|
||||
writeFileSync(join(root,file),readFileSync(join(root,file),'utf8')+'\n// changed fixture input\n');
|
||||
assert.throws(()=>image.verifyImage(receipt,root,inspect),/image inputs differ/);
|
||||
});
|
||||
|
||||
@@ -1027,3 +1027,22 @@ test("bus-service.sh refuses a default claude without the execute bit, or in a P
|
||||
assert.ok(r.stderr.includes(join(spaced, "claude")), r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
});
|
||||
|
||||
// Row 191: a restarted halted instance must keep its recovery bus reachable.
|
||||
test('bus start --pm while halted keeps the control host up without launching the chief', { timeout: 30000 }, async t => {
|
||||
const { initializeControl, transition, readControl } = await import('../../processing-control/src/state.mjs');
|
||||
const f = fixture(tmp(t)); notifyConfig(f.dataRoot, 'acme', null);
|
||||
initializeControl(f.dataRoot);
|
||||
transition(f.dataRoot, { business: 'acme', human: 'jason', halted: true });
|
||||
const child = spawn(process.execPath, [CLI, 'bus', 'start', 'acme', '--pm'], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const exited = once(child, 'exit'); let output = '';
|
||||
child.stdout.on('data', b => { output += b; }); child.stderr.on('data', b => { output += b; });
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } });
|
||||
await until(() => /processing halted: chief launch skipped/.test(output) || child.exitCode !== null);
|
||||
assert.match(output, /processing halted: chief launch skipped/);
|
||||
assert.equal(child.exitCode, null, output);
|
||||
const status = spawnSync(process.execPath, [CLI, 'bus', 'status', '--json'], { env: f.env, encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).host.live, true);
|
||||
assert.equal(readControl(f.dataRoot).halted, true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance
|
||||
// add|set` reach the broker's human-only verbs, which write through the business package's
|
||||
// instance writer. The bus here is a real startBroker over a temporary organization directory.
|
||||
// Row 194 (#1692, D3): it boots from the file through bootConfig, as the unit does, so a create goes
|
||||
// live with no restart. A stub boot whose credential references differ from the file still answers
|
||||
// restart needed.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
@@ -11,20 +14,26 @@ import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { main, run } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { RESTART_NEEDED } from "../src/instances.mjs";
|
||||
import { fixture, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
|
||||
async function setup(t) {
|
||||
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
|
||||
// request on as the launcher's pass would).
|
||||
async function setup(t, { stub = false, hook = null } = {}) {
|
||||
const root = tmp(t, "mosaic-cli-instances-");
|
||||
const f = fixture(root, "acme");
|
||||
const dir = join(root, "config");
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
||||
businesses: stub
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
: boot.businesses,
|
||||
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
@@ -33,6 +42,7 @@ async function setup(t) {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
transport: (business) => async (verb, args = {}) => {
|
||||
calls.push({ business, verb, args });
|
||||
await hook?.(verb, args, bus);
|
||||
try {
|
||||
return structuredClone(bus.broker.request(human, { verb, args }));
|
||||
} catch (e) {
|
||||
@@ -54,10 +64,10 @@ async function setup(t) {
|
||||
return { code, out: x.out.text, err: x.err.text };
|
||||
};
|
||||
const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision;
|
||||
return { root, f, file, log, validate, mosaic, revision, calls };
|
||||
return { root, f, file, log, validate, mosaic, revision, calls, bus };
|
||||
}
|
||||
|
||||
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => {
|
||||
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, the log names the human, and the instance binds with no restart (row 194, T1)", async (t) => {
|
||||
const s = await setup(t);
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.equal(view.code, 0, view.err);
|
||||
@@ -65,17 +75,16 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
// Row 65: the cli fixture gives every instance its route vars, so the view lists them.
|
||||
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\) vars \{"harness":"pi","model":"glm-5\.3-flash","thinking":"high"\}$/m);
|
||||
assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m);
|
||||
assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m);
|
||||
assert.match(view.out, /^definitions that declare credentials, added with store references and a restart: .*\breviewer \(gitea, vikunja\)/m);
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
const entry = s.log().at(-1);
|
||||
const now = await s.revision();
|
||||
assert.deepEqual(added.out.split("\n"), [
|
||||
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "",
|
||||
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`,
|
||||
"no restart: the launcher reads the change at its next launch check", "",
|
||||
]);
|
||||
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
|
||||
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
|
||||
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
|
||||
assert.ok(s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), "the running bus binds the new instance");
|
||||
const doc = JSON.parse(readFileSync(s.file, "utf8"));
|
||||
assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" });
|
||||
assert.ok(doc.launch.instances.includes("sec-1"));
|
||||
@@ -84,6 +93,21 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
assert.equal(checked.status, 0, checked.stderr);
|
||||
});
|
||||
|
||||
test("row 194 CLI: a bus whose boot credential references differ from the file answers restart needed, and doesn't bind the instance (D3 item 3)", async (t) => {
|
||||
const s = await setup(t, { stub: true });
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
assert.equal(added.out.split("\n").at(-2), RESTART_NEEDED);
|
||||
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
|
||||
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
|
||||
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
|
||||
assert.throws(() => s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), { code: "unknown-role" });
|
||||
// Row 194 b6 (webby-0094 B1): a veto on the same bus prints the restart line too.
|
||||
const kept = await s.mosaic(["veto", "acme", "keep", "sec-1", "--revision", await s.revision()]);
|
||||
assert.equal(kept.code, 0, kept.err);
|
||||
assert.equal(kept.out.split("\n").at(-2), RESTART_NEEDED);
|
||||
});
|
||||
|
||||
test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => {
|
||||
const s = await setup(t);
|
||||
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
|
||||
@@ -103,7 +127,9 @@ test("row 165 CLI: a refusal names its code and why, exits 2 and keeps the bytes
|
||||
const rev = await s.revision();
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const cases = [
|
||||
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: this command binds no credentials/],
|
||||
// Row 194 (brief item 7, sage-1610 a): a vikunja definition needs --bot and --bot-id.
|
||||
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-tracker-required: the definition uses vikunja; add needs --bot BOT --bot-id N/],
|
||||
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--bot", "bot-acme-reviewer", "--bot-id", "40", "--revision", rev], /^mosaic: organization-tracker-bot-taken: /],
|
||||
[["instance", "set", "acme", "coder", "--definition", "security-reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: /],
|
||||
[["instance", "add", "acme", "coder", "--definition", "merge-gate", "--revision", rev], /^mosaic: organization-instance-exists: /],
|
||||
[["instance", "set", "acme", "ghost", "--launch", "--revision", rev], /^mosaic: organization-unknown-instance: /],
|
||||
@@ -143,12 +169,170 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session
|
||||
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
|
||||
});
|
||||
|
||||
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
|
||||
test("row 165: scripts/mosaic sends business instances, instance and (row 194) veto to packages/cli, and validate still to packages/business", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
|
||||
assert.equal(routed.status, 4);
|
||||
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
|
||||
// Row 194: veto goes to packages/cli too.
|
||||
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
|
||||
assert.equal(veto.status, 4);
|
||||
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
|
||||
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
|
||||
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
|
||||
});
|
||||
|
||||
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
|
||||
const s = await setup(t);
|
||||
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(kept.code, 0, kept.err);
|
||||
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
|
||||
const entry = s.log().at(-1);
|
||||
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`,
|
||||
"no restart: the launcher reads the change at its next launch check", ""]);
|
||||
assert.equal(entry.actor, "jason");
|
||||
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
|
||||
assert.equal(nohire.code, 0, nohire.err);
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
|
||||
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
|
||||
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
|
||||
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
|
||||
// A kept instance refuses delete at the verb.
|
||||
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(del.code, 2);
|
||||
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
|
||||
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(again.code, 2);
|
||||
assert.match(again.err, /^mosaic: organization-no-change: /);
|
||||
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
|
||||
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
}
|
||||
const after = JSON.parse(readFileSync(s.file, "utf8"));
|
||||
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
|
||||
});
|
||||
|
||||
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
|
||||
const s = await setup(t);
|
||||
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
|
||||
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
|
||||
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
|
||||
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
|
||||
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
|
||||
});
|
||||
|
||||
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
|
||||
const closing = (done) => (verb, args, bus) => {
|
||||
if (verb !== "instance.request") return;
|
||||
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
|
||||
};
|
||||
|
||||
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
|
||||
let done = () => ({ run: "run-new" });
|
||||
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
|
||||
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(launched.code, 0, launched.err);
|
||||
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
|
||||
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
|
||||
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
|
||||
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
|
||||
assert.equal(regen.code, 0, regen.err);
|
||||
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
|
||||
done = () => ({ refusal: "capacity-full" });
|
||||
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(full.code, 3);
|
||||
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
|
||||
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
|
||||
done = () => ({ refusal: "some-new-code" });
|
||||
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(other.code, 3);
|
||||
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
|
||||
const s = await setup(t);
|
||||
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
|
||||
assert.equal(open.code, 3);
|
||||
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
|
||||
const t2 = await setup(t, { hook: closing(null) });
|
||||
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
|
||||
assert.equal(taken.code, 3);
|
||||
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
|
||||
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
|
||||
});
|
||||
|
||||
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
|
||||
const s = await setup(t, { hook: (verb, args, bus) => {
|
||||
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
|
||||
} });
|
||||
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
|
||||
const s = await setup(t);
|
||||
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
|
||||
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
|
||||
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
|
||||
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
|
||||
s.bus.lifecycleTake({ business: "acme", request: taken });
|
||||
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
|
||||
const lines = r.out.split("\n");
|
||||
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
|
||||
assert.deepEqual(lines.slice(1), [
|
||||
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
|
||||
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
|
||||
]);
|
||||
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
|
||||
assert.equal(none.code, 2);
|
||||
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
|
||||
let rename = null;
|
||||
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
|
||||
for (const name of ["sec-1", "sec-2"]) {
|
||||
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
}
|
||||
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
|
||||
assert.equal(del.code, 0, del.err);
|
||||
const entry = s.log().at(-1);
|
||||
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
|
||||
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
|
||||
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
|
||||
rename = "workspace-rename-failed";
|
||||
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
|
||||
assert.equal(failed.code, 3);
|
||||
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
|
||||
});
|
||||
|
||||
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
|
||||
const s = await setup(t);
|
||||
const rev = "a".repeat(64);
|
||||
for (const args of [
|
||||
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
|
||||
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
|
||||
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
|
||||
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
|
||||
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
|
||||
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
|
||||
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
|
||||
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
|
||||
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
|
||||
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
|
||||
]) {
|
||||
const r = await s.mosaic(args);
|
||||
assert.equal(r.code, 4, args.join(" "));
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
@@ -19,6 +20,7 @@ function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_
|
||||
if(account)doc.launch.account=account;
|
||||
return doc;
|
||||
}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
|
||||
for(const k of set)f.env[k]=VALUE;
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
|
||||
@@ -6,14 +6,16 @@ import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
|
||||
const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1};
|
||||
|
||||
// answer is what the mayLaunch op returns, or an Error it throws.
|
||||
function setup(t,answer){
|
||||
function setup(t,answer,{initialize=true}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[];
|
||||
if(initialize)initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
if(x.op==='identity')return {business:'acme',role:'pm'};
|
||||
@@ -78,3 +80,11 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche
|
||||
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
|
||||
assert.doesNotMatch(src,/\bimport\s*\(/);
|
||||
});
|
||||
|
||||
// Row 191: only trusted bus startup initializes control; an early seat launch refuses.
|
||||
test('a launcher before trusted control initialization refuses processing-uninitialized',async t=>{
|
||||
const {launcher,ops}=setup(t,{allow:true},{initialize:false});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'processing-uninitialized'});
|
||||
assert.equal(ops.some(x=>x.op==='mayLaunch'||x.op==='authorizeLaunch'),false);
|
||||
assert.deepEqual(launcher.registry.sessions,[]);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import assert from 'node:assert/strict';
|
||||
import {join} from 'node:path';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session);
|
||||
|
||||
@@ -7,6 +7,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fakeHarnesses,fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
@@ -25,6 +26,7 @@ const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent
|
||||
// vars are the coder's whole agent vars; edit changes the rest of the file.
|
||||
async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
Object.assign(f.env,more);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
|
||||
@@ -1075,3 +1075,49 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
|
||||
assert.equal(ends.length, 1);
|
||||
assert.equal(ends[0].signal, "SIGKILL");
|
||||
});
|
||||
|
||||
// Row 191: root-scoped processing is stronger than per-business launch.restore.
|
||||
test('kill switch refuses chief and hire before launch work and a clear permits a later request', { skip, timeout: 30000 }, async t => {
|
||||
const { readControl, transition } = await import('../../processing-control/src/state.mjs');
|
||||
const s = await setup(t);
|
||||
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
|
||||
await assert.rejects(s.launcher.launchPm(), /PM launch refused: processing-halted/);
|
||||
assert.deepEqual(await s.launcher.fromSession(s.cto.cap, 'coder'), { ok: false, error: 'processing-halted' });
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: false, generation: halted.generation });
|
||||
const run = await s.launcher.launchPm();
|
||||
assert.equal(readControl(s.f.dataRoot).halted, false);
|
||||
assert.equal(readSessions(s.f.dataRoot)[0].run, run.run);
|
||||
});
|
||||
|
||||
test('kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle', { skip, timeout: 30000 }, async t => {
|
||||
const { transition } = await import('../../processing-control/src/state.mjs');
|
||||
const s = await setup(t);
|
||||
const run = await s.launcher.launchPm();
|
||||
const entry = readSessions(s.f.dataRoot).find(r => r.run === run.run);
|
||||
const session = readFileSync(join(entry.runDir, 'session.json'));
|
||||
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
|
||||
await until(() => !readSessions(s.f.dataRoot).some(r => r.run === run.run), 12000);
|
||||
assert.deepEqual(readFileSync(join(entry.runDir, 'session.json')), session);
|
||||
const end = log(s.f).find(r => r.event === 'end' && r.run === run.run);
|
||||
assert.equal(end.reason, 'kill-switch');
|
||||
assert.equal(end.haltGeneration, halted.generation);
|
||||
assert.equal(startTimeOf(entry.runnerPid), null);
|
||||
});
|
||||
|
||||
test('a launch waiting across halt and resume cannot use its stale admission', { skip, timeout: 30000 }, async t => {
|
||||
const { transition } = await import('../../processing-control/src/state.mjs');
|
||||
let root, first = true;
|
||||
const prepared = prepare(t); root = prepared.f.dataRoot;
|
||||
const s = await setup(t, doc => doc, prepared, { wrap: host => ({ ...host, async op(m) {
|
||||
const answer = await host.op(m);
|
||||
if (m.op === 'mayLaunch' && first) {
|
||||
first = false;
|
||||
const state = transition(root, { business: 'acme', human: 'jason', halted: true });
|
||||
transition(root, { business: 'acme', human: 'jason', halted: false, generation: state.generation });
|
||||
}
|
||||
return answer;
|
||||
} }) });
|
||||
await assert.rejects(s.launcher.launchPm(), /processing-generation-changed/);
|
||||
assert.deepEqual(readSessions(root), []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
|
||||
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
|
||||
// the launcher's lifecycle pass takes each one, acts on it and closes it. Real runners in the bwrap
|
||||
// sandbox on the fake adapter.
|
||||
//
|
||||
// The host is in process, as in thread-launch.test.mjs: the real host proves a human CLI process
|
||||
// for every human verb, which a test run under an agent can't. The tick interval is long and each
|
||||
// test calls tick() itself, so a request waits until the test says.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { processIdentity, registerRuntime, sweepRuntimes, unregisterRuntime, updateRuntime } from "../../processing-control/src/runtimes.mjs";
|
||||
import { controlDirectory, transition } from "../../processing-control/src/state.mjs";
|
||||
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { run } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// chief opus, coder and reviewer sonnet, at most one sonnet run. Only coder is listed, so reviewer
|
||||
// is an instance the chief may not launch and the human may. sec-1 holds no credential, so the
|
||||
// organization writer lets the human delete it. assistant is the thread instance.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-life-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 1 });
|
||||
return edit(doc);
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, options = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
// The real host makes bus-host/ (0700). A --no-launch launcher's read-only registry doesn't.
|
||||
mkdirSync(join(f.dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
// A host op named here throws once, as a bus that refused it would.
|
||||
const faults = new Set();
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
if (faults.delete(m.op)) throw new BusError("storage-refused");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
...options,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const state = (request) => call("instance.request", { request });
|
||||
// One pass, then the request's state.
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return state(request);
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
const claimed = (run) => until(() => {
|
||||
const file = join(f.dataRoot, "launches", "acme", run, "runner.log");
|
||||
return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run");
|
||||
});
|
||||
// The CLI through the in-process human. Each poll of instance.request runs one pass first, so a
|
||||
// --wait sees the request closed as the launcher's ticker would close it.
|
||||
const cli = async (args) => {
|
||||
const x = io(f.env);
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
transport: () => async (verb, a = {}) => {
|
||||
if (verb === "instance.request") await launcher.tick();
|
||||
try {
|
||||
return structuredClone(call(verb, a));
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
throw Object.assign(new Error(e.code), { code: e.code });
|
||||
}
|
||||
},
|
||||
};
|
||||
const code = await run(["business", ...args], x, deps);
|
||||
return { code, out: x.out.text, err: x.err.text };
|
||||
};
|
||||
return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
|
||||
// Stops a runner from outside its namespace, so it can't act on SIGTERM, and kills it at the end.
|
||||
function stop(t, s) {
|
||||
process.kill(s.runnerPid, "SIGSTOP");
|
||||
t.after(() => {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
// S6: coder and reviewer listed, so a fire of coder isn't of the last listed name.
|
||||
const listed = (doc) => {
|
||||
doc.launch = launchDoc("chief", ["coder", "reviewer"], { opus: 1, sonnet: 1 });
|
||||
return doc;
|
||||
};
|
||||
|
||||
// S6: a stand-in runner holding a stand-in model variable, with a registry entry and a row 191
|
||||
// runtime record as start() makes them. One with `ignoreTerm` stays through a SIGTERM. Each SIGTERM
|
||||
// it gets is a line it prints, so terms() counts what reached it from any sender. One without
|
||||
// `managed` has no runtime record, so row 191's halt sweep doesn't see it.
|
||||
async function standIn(t, p, s, { instance, run, ignoreTerm = false, managed = true }) {
|
||||
const code = `process.on("SIGTERM", () => { process.stdout.write("term\\n"); ${ignoreTerm ? "" : "process.exit(143);"} }); process.stdout.write("ready\\n"); setInterval(() => {}, 1000);`;
|
||||
const child = spawn(process.execPath, ["-e", code], { stdio: ["ignore", "pipe", "ignore"], env: { PATH: process.env.PATH, MOSAIC_STAND_IN_MODEL_TOKEN: "stand-in" } });
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
});
|
||||
let out = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal ?? code)));
|
||||
await until(() => out.includes("ready"));
|
||||
const startTime = startTimeOf(child.pid);
|
||||
s.launcher.registry.add({ business: "acme", instance, run, pid: child.pid, startTime, runnerPid: child.pid, runnerStartTime: startTime, startedAt: new Date().toISOString() });
|
||||
t.after(() => s.launcher.registry.remove("acme", run));
|
||||
if (managed) {
|
||||
const record = registerRuntime(p.f.dataRoot, { kind: "session", business: "acme", run });
|
||||
updateRuntime(p.f.dataRoot, record.id, { ...processIdentity(child.pid), group: false, phase: "running" });
|
||||
t.after(() => {
|
||||
try { unregisterRuntime(p.f.dataRoot, record.id); } catch {}
|
||||
});
|
||||
}
|
||||
return {
|
||||
run,
|
||||
pid: child.pid,
|
||||
exited,
|
||||
gone: () => child.exitCode !== null || child.signalCode !== null,
|
||||
terms: () => out.split("\n").filter((l) => l === "term").length,
|
||||
};
|
||||
}
|
||||
|
||||
// A bus session of the role with its claim, as a launched run's bind and claim leave it.
|
||||
function bind(s, role, run) {
|
||||
const cap = s.runtime.broker.bindLaunch({ business: "acme", role, run, harness: "pi" });
|
||||
s.runtime.broker.request(cap, { verb: "role.claim" });
|
||||
return cap;
|
||||
}
|
||||
const fire = (s, chief, name) => s.runtime.broker.request(chief, { verb: "instance.fire", args: { name, reason: "the review is done" } });
|
||||
|
||||
const allowed = (p, action) => {
|
||||
const db = new DatabaseSync(join(p.f.dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db
|
||||
.prepare("SELECT subject, body FROM events WHERE kind='action.allowed' ORDER BY seq")
|
||||
.all()
|
||||
.map((e) => ({ subject: e.subject, body: JSON.parse(e.body) }))
|
||||
.filter((e) => e.body.action === action);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test("row 194 T2: the human's launch runs an unlisted instance and launch.by through the pm branch, and closes with the run", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const { request } = s.call("instance.launch", { instance: "reviewer" });
|
||||
assert.equal(s.state(request).state, "open");
|
||||
const done = await s.carry(request);
|
||||
assert.equal(done.state, "closed");
|
||||
assert.equal(done.done.refusal, null);
|
||||
assert.match(done.done.run, /^r[0-9a-f]{12}$/);
|
||||
const [r] = s.running("reviewer");
|
||||
assert.equal(r.run, done.done.run);
|
||||
const session = readSessions(p.f.dataRoot).find((x) => x.run === r.run);
|
||||
assert.equal(session.launchedBy, "jason");
|
||||
await s.claimed(r.run);
|
||||
// launch.by is not on launch.instances. The human's request takes the pm branch, as --pm does.
|
||||
const chief = s.call("instance.launch", { instance: "chief" }).request;
|
||||
const c = await s.carry(chief);
|
||||
assert.equal(c.done.refusal, null);
|
||||
assert.equal(s.running("chief")[0].run, c.done.run);
|
||||
// A request is taken once: the next pass starts nothing more.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("reviewer").length, 1);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 2);
|
||||
});
|
||||
|
||||
test("row 194 T2: a request past its 60 s never launches, and a refused check closes the request with its code", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const old = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const now = Date.now();
|
||||
t.mock.method(Date, "now", () => now + 61000);
|
||||
const expired = await s.carry(old);
|
||||
assert.deepEqual([expired.state, expired.done.refusal, expired.done.run], ["closed", "request-expired", null]);
|
||||
t.mock.restoreAll();
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
// A check refusal: the organization file now names a harness that isn't switched on.
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.coder.vars.harness = "claude-code";
|
||||
doc.plugins.harness = ["pi"];
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2));
|
||||
const off = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const refused = await s.carry(off);
|
||||
assert.deepEqual([refused.state, refused.done.refusal, refused.done.run], ["closed", "harness-off", null]);
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === off);
|
||||
assert.deepEqual([entry.instance, entry.code, entry.by], ["coder", "harness-off", "jason"]);
|
||||
});
|
||||
|
||||
test("row 194 T3: a --no-launch host closes each request launches-disabled and starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p, { launches: false });
|
||||
const launch = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const regen = s.call("instance.regenerate", { instance: "reviewer" }).request;
|
||||
await s.launcher.tick();
|
||||
for (const request of [launch, regen]) assert.deepEqual([s.state(request).state, s.state(request).done.refusal], ["closed", "launches-disabled"]);
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
|
||||
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "launch"), []);
|
||||
});
|
||||
|
||||
test("row 194 T3: a limit refusal closes the request with its code and the limit in the launch log", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const first = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(first.done.refusal, null);
|
||||
// One sonnet run at a time: reviewer runs sonnet too.
|
||||
const full = s.call("instance.launch", { instance: "reviewer" }).request;
|
||||
const r = await s.carry(full);
|
||||
assert.deepEqual([r.state, r.done.refusal, r.done.run], ["closed", "capacity-full", null]);
|
||||
assert.equal(s.running("reviewer").length, 0);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === full);
|
||||
assert.equal(entry.code, "capacity-full");
|
||||
assert.ok(entry.limit, JSON.stringify(entry));
|
||||
// The instance already running refuses a second launch of itself.
|
||||
const again = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(again.done.refusal, "instance-running");
|
||||
});
|
||||
|
||||
test("row 194 T4: terminate revokes and cancels at the verb, then the pass signals the run, which ends once", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const launched = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
const run = launched.done.run;
|
||||
await s.claimed(run);
|
||||
const open = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const out = s.call("instance.end", { instance: "coder", reason: "stuck" });
|
||||
assert.deepEqual([out.runs, out.cancelled], [[run], [open]]);
|
||||
assert.equal(s.state(open).state, "closed");
|
||||
const end = await s.carry(out.request);
|
||||
assert.deepEqual([end.state, end.done], ["closed", { run: null, transaction: null, refusal: null }]);
|
||||
await until(() => s.running("coder").length === 0);
|
||||
const log = launchLog(p.f);
|
||||
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === run).length, 1);
|
||||
await until(() => launchLog(p.f).some((e) => e.event === "end" && e.run === run));
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "end" && e.run === run).length, 1);
|
||||
// The cancelled request never ran.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
});
|
||||
|
||||
test("row 194 T5: regenerate ends the run in the registry and starts a new run on the same workspace", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await s.claimed(first);
|
||||
const marker = join(workspaceDir(p.f.dataRoot, "acme", "coder"), "kept.txt");
|
||||
writeFileSync(marker, "kept\n");
|
||||
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
|
||||
assert.equal(regen.done.refusal, null);
|
||||
const second = regen.done.run;
|
||||
assert.notEqual(second, first);
|
||||
assert.deepEqual(s.running("coder").map((x) => x.run), [second]);
|
||||
assert.ok(existsSync(join(p.f.dataRoot, "launches", "acme", second)));
|
||||
assert.equal(readFileSync(marker, "utf8"), "kept\n");
|
||||
const log = launchLog(p.f);
|
||||
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === first).length, 1);
|
||||
assert.equal(log.filter((e) => e.event === "end" && e.run === first).length, 1);
|
||||
// A thread instance is never regenerated: the verb refuses before any request.
|
||||
assert.throws(() => s.call("instance.regenerate", { instance: "assistant" }), (e) => e.code === "regenerate-thread");
|
||||
});
|
||||
|
||||
test("row 194 T5: a run that outlasts the request's mark closes the regenerate regenerate-timeout and starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p, { lifecycleRequestMs: 1500 });
|
||||
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await s.claimed(first);
|
||||
stop(t, s.running("coder")[0]);
|
||||
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
|
||||
assert.deepEqual([regen.state, regen.done.refusal, regen.done.run], ["closed", "regenerate-timeout", null]);
|
||||
assert.deepEqual(s.running("coder").map((x) => x.run), [first]);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
|
||||
});
|
||||
|
||||
test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<transaction> at 0700, and a retry renames after workspace-rename-failed", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const ws = workspaceDir(p.f.dataRoot, "acme", "sec-1");
|
||||
mkdirSync(ws, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(ws, "notes.txt"), "mine\n");
|
||||
// A file where .retired goes makes the rename fail after the commit.
|
||||
const retired = join(workspaceDir(p.f.dataRoot, "acme", ".retired"));
|
||||
writeFileSync(retired, "in the way\n");
|
||||
const revision = () => s.call("instances").revision;
|
||||
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
const failed = await s.carry(del);
|
||||
assert.equal(failed.done.refusal, "workspace-rename-failed");
|
||||
const transaction = failed.done.transaction;
|
||||
assert.ok(transaction);
|
||||
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
|
||||
assert.equal(Object.hasOwn(file.roles, "sec-1"), false, "the commit stands");
|
||||
assert.ok(file.retired.includes("sec-1"));
|
||||
assert.equal(readFileSync(join(ws, "notes.txt"), "utf8"), "mine\n", "the workspace stays where it was");
|
||||
// A retired name launches no more, so nothing opens the old workspace before the retry.
|
||||
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
|
||||
rmSync(retired);
|
||||
const retry = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
assert.equal(s.state(retry).retry, true);
|
||||
const ok = await s.carry(retry);
|
||||
assert.deepEqual(ok.done, { run: null, transaction, refusal: null });
|
||||
const to = join(retired, `sec-1-${transaction}`);
|
||||
assert.equal(existsSync(ws), false);
|
||||
assert.equal(readFileSync(join(to, "notes.txt"), "utf8"), "mine\n");
|
||||
assert.equal(statSync(retired).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(to).mode & 0o777, 0o700);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "workspace-retired");
|
||||
assert.deepEqual([entry.instance, entry.request, entry.transaction, entry.path], ["sec-1", retry, transaction, to]);
|
||||
});
|
||||
|
||||
test("row 194 V5: a delete with no workspace commits and closes with the transaction", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
|
||||
const r = await s.carry(del);
|
||||
assert.equal(r.done.refusal, null);
|
||||
assert.ok(r.done.transaction);
|
||||
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
|
||||
});
|
||||
|
||||
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
|
||||
function snapshot(root) {
|
||||
const out = {};
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const st = lstatSync(path);
|
||||
if (st.isDirectory()) walk(path);
|
||||
else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const launched = await s.cli(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(launched.code, 0, launched.err);
|
||||
const [r] = s.running("reviewer");
|
||||
assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`));
|
||||
await s.claimed(r.run);
|
||||
const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]);
|
||||
assert.equal(regen.code, 0, regen.err);
|
||||
const [r2] = s.running("reviewer");
|
||||
assert.notEqual(r2.run, r.run);
|
||||
assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`));
|
||||
await s.claimed(r2.run);
|
||||
// One sonnet run at a time: the launcher closes coder's request capacity-full.
|
||||
const full = await s.cli(["instance", "launch", "acme", "coder"]);
|
||||
assert.equal(full.code, 3);
|
||||
const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder");
|
||||
assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m"));
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]);
|
||||
assert.equal(ended.code, 0, ended.err);
|
||||
assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m"));
|
||||
await s.launcher.tick();
|
||||
await until(() => s.running("reviewer").length === 0);
|
||||
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
|
||||
const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]);
|
||||
assert.equal(del.code, 0, del.err);
|
||||
assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/);
|
||||
assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m);
|
||||
});
|
||||
|
||||
// Row 194 (brief item 8, sage-1610 b): an instance whose every credential is a store reference
|
||||
// deletes with its tracker bot. The launcher carries the bot to the close, and the CLI names it for
|
||||
// removal in Vikunja, since the bus makes and removes no Vikunja account.
|
||||
test("row 194 T20: a store-only delete with a tracker bot closes naming the bot, and the CLI says to remove it in Vikunja", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.roles["rev-2"] = { definition: "reviewer", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" }, tracker: { bot: "bot-acme-rev-2", botId: 40 },
|
||||
credentials: { gitea: { store: true, rotateBy: "2099-01-01" }, vikunja: { store: true, expires: "2099-01-01" } } };
|
||||
return doc;
|
||||
});
|
||||
// The agents' store root beside the business files, private as the boot check needs it.
|
||||
mkdirSync(join(p.root, "config", "secrets", "agents"), { recursive: true, mode: 0o700 });
|
||||
const s = await start(t, p);
|
||||
// Deviation 11 (sage-1620): a file the store doesn't own fails the revoke. The CLI says the business
|
||||
// file is unchanged and a retry is safe, and rev-2 stays listed.
|
||||
const store = join(p.root, "config", "secrets", "agents", "acme", "rev-2");
|
||||
mkdirSync(store, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(store, "notes"), "x");
|
||||
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
|
||||
const failed = await s.cli(["instance", "delete", "acme", "rev-2", "--revision", revision]);
|
||||
assert.equal(failed.code, 3, failed.err);
|
||||
assert.match(failed.err, /^mosaic: credential-revoke-failed: .*business file is unchanged; tokens already revoked stay revoked .*which is safe \(request [^)]+\)$/m);
|
||||
assert.equal(JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision, revision);
|
||||
rmSync(join(store, "notes"));
|
||||
const del = await s.cli(["instance", "delete", "acme", "rev-2", "--revision", revision]);
|
||||
assert.equal(del.code, 0, del.err);
|
||||
assert.match(del.out, /^business acme: deleted rev-2, now retired\ntransaction [^\n]+\ntracker bot bot-acme-rev-2: remove the Vikunja bot user and any token it holds by hand in Vikunja\n$/);
|
||||
// The line reads done.bot, which only the launcher's close writes.
|
||||
assert.equal(Object.hasOwn(JSON.parse((await s.cli(["instances", "acme", "--json"])).out).instances, "rev-2"), false);
|
||||
});
|
||||
|
||||
test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const request = s.call("instance.launch", { instance: "coder" }).request;
|
||||
s.call("launch.revoke");
|
||||
const revoked = await s.carry(request);
|
||||
assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]);
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
|
||||
s.call("launch.restore");
|
||||
const held = s.call("instance.launch", { instance: "coder" }).request;
|
||||
s.faults.add("lifecycleDone");
|
||||
const taken = await s.carry(held);
|
||||
assert.deepEqual([taken.state, taken.done], ["taken", null]);
|
||||
const [r] = s.running("coder");
|
||||
assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n"));
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.state(held).state, "taken");
|
||||
assert.equal(s.running("coder").length, 1);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
|
||||
});
|
||||
|
||||
test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const spawned = (instance) => {
|
||||
const child = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
t.after(() => child.kill("SIGKILL"));
|
||||
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal)));
|
||||
return { child, exited, instance };
|
||||
};
|
||||
const a = spawned("coder");
|
||||
const b = spawned("reviewer");
|
||||
await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null);
|
||||
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
|
||||
for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]])
|
||||
s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start, startedAt: new Date().toISOString() });
|
||||
t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`)));
|
||||
for (const x of [a, b]) {
|
||||
s.call("instance.launch", { instance: x.instance });
|
||||
const out = s.call("instance.end", { instance: x.instance });
|
||||
assert.deepEqual([out.runs, out.cancelled.length], [[], 1]);
|
||||
await s.carry(out.request);
|
||||
}
|
||||
assert.equal(await a.exited, "SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(b.child.exitCode, null);
|
||||
assert.equal(b.child.signalCode, null);
|
||||
const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run);
|
||||
assert.deepEqual(ends, ["run-coder", "run-reviewer"]);
|
||||
});
|
||||
|
||||
// S6 (D2.2): fire's end goes through row 191's stopRun, which escalates to SIGKILL after the grace.
|
||||
// The stuck stand-in's SIGKILL is stubbed to report delivery and do nothing, as a failed kill would.
|
||||
test("row 194 T9a: fire signals its bound run and a spawned but unbound registry run; a SIGTERM-ignoring runner gets SIGKILL and lifecycle.end.killed, and one whose kill fails gets lifecycle.end.alive", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, listed);
|
||||
const signals = [];
|
||||
const signal = (r, sig) => {
|
||||
signals.push([r.run, sig]);
|
||||
if (r.run !== "coder-stuck" || sig === "SIGTERM") process.kill(r.pid, sig);
|
||||
return true;
|
||||
};
|
||||
const s = await start(t, p, { stopOptions: { graceMs: 400, killWaitMs: 400, signal } });
|
||||
const bound = await standIn(t, p, s, { instance: "coder", run: "coder-run", ignoreTerm: true });
|
||||
const spare = await standIn(t, p, s, { instance: "coder", run: "coder-spare" });
|
||||
const stuck = await standIn(t, p, s, { instance: "coder", run: "coder-stuck", ignoreTerm: true });
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
// The bus knows only the bound run. The pass takes the spawned ones from the registry.
|
||||
assert.deepEqual(fired.runs, ["coder-run"]);
|
||||
await s.launcher.tick();
|
||||
assert.equal(await bound.exited, "SIGKILL");
|
||||
assert.equal(await spare.exited, 143);
|
||||
assert.equal(stuck.gone(), false);
|
||||
assert.deepEqual([bound.terms(), stuck.terms()], [1, 1]);
|
||||
assert.deepEqual(signals.filter(([r]) => r === "coder-spare"), [["coder-spare", "SIGTERM"]]);
|
||||
const events = (action) => allowed(p, action).map((e) => [e.subject, e.body.request, e.body.run, e.body.pid]);
|
||||
assert.deepEqual(events("lifecycle.end.killed"), [["coder", fired.request, "coder-run", bound.pid]]);
|
||||
assert.deepEqual(events("lifecycle.end.alive"), [["coder", fired.request, "coder-stuck", stuck.pid]]);
|
||||
assert.equal(allowed(p, "lifecycle.done").filter((e) => e.body.request === fired.request).length, 1);
|
||||
// D7: the Console's listing flags the run fire left alive.
|
||||
assert.deepEqual(s.call("instances").instances.coder.alive, ["coder-stuck"]);
|
||||
const stops = launchLog(p.f).filter((e) => e.event === "fire-stop" && e.request === fired.request);
|
||||
assert.deepEqual(stops.map((e) => [e.run, e.outcome]).sort(), [["coder-run", "killed"], ["coder-spare", "stopped"], ["coder-stuck", "alive"]]);
|
||||
assert.deepEqual(stops.find((e) => e.run === "coder-run").signals, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
// sage-2289: a registry run with no row 191 runtime record makes stopRun answer unknown and send no
|
||||
// signal. Unknown can't confirm a kill, so it reads as alive with reason unknown, and the Console flags it.
|
||||
test("row 194 T9a: fire of a registry run with no runtime record reads lifecycle.end.alive with reason unknown, never killed", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, listed);
|
||||
const s = await start(t, p, { stopOptions: { graceMs: 400, killWaitMs: 400 } });
|
||||
const bound = await standIn(t, p, s, { instance: "coder", run: "coder-run" });
|
||||
const ghost = await standIn(t, p, s, { instance: "coder", run: "coder-ghost", managed: false });
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
assert.deepEqual(fired.runs, ["coder-run"]);
|
||||
await s.launcher.tick();
|
||||
assert.equal(await bound.exited, 143);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.deepEqual([ghost.gone(), ghost.terms()], [false, 0]);
|
||||
const events = (action) => allowed(p, action).map((e) => [e.subject, e.body.request, e.body.run, e.body.pid, e.body.reason]);
|
||||
assert.deepEqual(events("lifecycle.end.killed"), []);
|
||||
assert.deepEqual(events("lifecycle.end.alive"), [["coder", fired.request, "coder-ghost", null, "unknown"]]);
|
||||
assert.deepEqual(s.call("instances").instances.coder.alive, ["coder-ghost"]);
|
||||
const stops = launchLog(p.f).filter((e) => e.event === "fire-stop" && e.request === fired.request);
|
||||
assert.deepEqual(stops.map((e) => [e.run, e.outcome]).sort(), [["coder-ghost", "unknown"], ["coder-run", "stopped"]]);
|
||||
});
|
||||
|
||||
// The fake clock makes the stop's steps exact: each identity look is counted at its instant, and
|
||||
// the second look at the first instant past the grace is stopRun's re-check just before the SIGKILL.
|
||||
test("row 194 T9a: a runner whose identity changed before the SIGTERM gets no signal, and one whose identity changed between the SIGTERM and the SIGKILL gets no SIGKILL", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, listed);
|
||||
const GRACE = 200;
|
||||
let clock = 0;
|
||||
const sent = [];
|
||||
const looks = new Map();
|
||||
const changed = new Set();
|
||||
const pids = {};
|
||||
const identity = (pid) => {
|
||||
const real = processIdentity(pid);
|
||||
if (!real) return real;
|
||||
if (pid === pids.between && !changed.has(pid)) {
|
||||
const c = looks.get(pid) ?? { first: clock, at: clock, n: 0 };
|
||||
if (c.at !== clock) Object.assign(c, { at: clock, n: 0 });
|
||||
c.n += 1;
|
||||
looks.set(pid, c);
|
||||
if (clock >= c.first + GRACE && c.n === 2) changed.add(pid);
|
||||
}
|
||||
return pid === pids.before || changed.has(pid) ? { ...real, startTime: String(Number(real.startTime) + 1) } : real;
|
||||
};
|
||||
const stopOptions = {
|
||||
graceMs: GRACE,
|
||||
killWaitMs: GRACE,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
await new Promise((r) => setImmediate(r));
|
||||
},
|
||||
identity,
|
||||
signal: (r, sig) => {
|
||||
sent.push([r.run, sig]);
|
||||
process.kill(r.pid, sig);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const s = await start(t, p, { stopOptions });
|
||||
const before = await standIn(t, p, s, { instance: "coder", run: "coder-before", ignoreTerm: true });
|
||||
const between = await standIn(t, p, s, { instance: "coder", run: "coder-between", ignoreTerm: true });
|
||||
Object.assign(pids, { before: before.pid, between: between.pid });
|
||||
bind(s, "coder", "coder-between");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
await s.launcher.tick();
|
||||
assert.deepEqual(sent, [["coder-between", "SIGTERM"]]);
|
||||
assert.deepEqual([before.gone(), between.gone(), before.terms(), between.terms()], [false, false, 0, 1]);
|
||||
assert.ok(changed.has(between.pid), "the identity changed at the SIGKILL's re-check");
|
||||
const stops = launchLog(p.f).filter((e) => e.event === "fire-stop" && e.request === fired.request);
|
||||
assert.deepEqual(stops.map((e) => [e.run, e.outcome]).sort(), [["coder-before", "identity-changed"], ["coder-between", "identity-changed"]]);
|
||||
assert.deepEqual([allowed(p, "lifecycle.end.killed"), allowed(p, "lifecycle.end.alive")], [[], []]);
|
||||
});
|
||||
|
||||
// V3: terminate sends the SIGTERM only, whatever the grace.
|
||||
test("row 194 T9a: V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, listed);
|
||||
const s = await start(t, p, { stopOptions: { graceMs: 200, killWaitMs: 200 } });
|
||||
const x = await standIn(t, p, s, { instance: "reviewer", run: "reviewer-run", ignoreTerm: true });
|
||||
bind(s, "reviewer", "reviewer-run");
|
||||
const out = s.call("instance.end", { instance: "reviewer", reason: "stuck" });
|
||||
assert.deepEqual(out.runs, ["reviewer-run"]);
|
||||
await s.carry(out.request);
|
||||
await until(() => x.terms() === 1);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
assert.deepEqual([x.gone(), x.terms()], [false, 1]);
|
||||
assert.equal(launchLog(p.f).some((e) => e.event === "fire-stop"), false);
|
||||
});
|
||||
|
||||
// Row 191's halt sweep joins a fire's stop of the same run in flight: one owner, one deadline, one
|
||||
// receipt. The sweep's own grace is its default 5 s, so a restarted deadline shows in the time.
|
||||
test("row 194 T9b: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason fire and the request, and counts the run; a fire while halted refuses", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, listed);
|
||||
const GRACE = 1500, OBSERVE = 500;
|
||||
const s = await start(t, p, { stopOptions: { graceMs: GRACE, killWaitMs: OBSERVE } });
|
||||
const x = await standIn(t, p, s, { instance: "coder", run: "coder-run", ignoreTerm: true });
|
||||
const chief = bind(s, "chief", "chief-run");
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, chief, "coder");
|
||||
const began = Date.now();
|
||||
const pass = s.launcher.tick();
|
||||
await until(() => x.terms() === 1);
|
||||
const halted = transition(p.f.dataRoot, { business: "acme", human: p.f.doc.human, halted: true });
|
||||
const swept = await sweepRuntimes(p.f.dataRoot, halted.generation);
|
||||
const took = Date.now() - began;
|
||||
await pass;
|
||||
assert.equal(await x.exited, "SIGKILL");
|
||||
assert.equal(x.terms(), 1);
|
||||
assert.ok(took < GRACE + OBSERVE + 1000, `the halt kept the fire's deadline (${took} ms)`);
|
||||
const dir = join(controlDirectory(p.f.dataRoot), "run-stops");
|
||||
const receipts = readdirSync(dir).filter((n) => n.endsWith(".result.json")).map((n) => JSON.parse(readFileSync(join(dir, n), "utf8")));
|
||||
assert.deepEqual(receipts.map((r) => [r.run, r.reason, r.owner, r.outcome]), [["coder-run", "fire", { decision: fired.request }, "killed"]]);
|
||||
const counted = swept.results.find((r) => r.run === "coder-run");
|
||||
assert.deepEqual([counted.outcome, counted.reason, counted.owner, counted.stop], ["killed", "fire", { decision: fired.request }, receipts[0].id]);
|
||||
assert.equal(swept.remaining, 0);
|
||||
assert.ok(swept.stopped >= 1);
|
||||
assert.deepEqual(allowed(p, "lifecycle.end.killed").map((e) => e.body.run), ["coder-run"]);
|
||||
// D6: the chief's fire refuses while halted, before step A.
|
||||
assert.throws(() => fire(s, chief, "reviewer"), (e) => e.code === "processing-halted");
|
||||
});
|
||||
|
||||
// D6 with row 191's switch: while halted a tick runs only the lifecycle pass. Launch and
|
||||
// regenerate requests opened before the halt close processing-halted, and terminate still signals.
|
||||
test("row 194 T15: while halted the pass closes a launch and a regenerate processing-halted and runs no thread pass, terminate signals, and after a resume a launch starts", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
let asked = 0;
|
||||
const threadsState = s.runtime.threadsState;
|
||||
s.runtime.threadsState = (business) => {
|
||||
asked += 1;
|
||||
return threadsState(business);
|
||||
};
|
||||
const x = await standIn(t, p, s, { instance: "reviewer", run: "reviewer-run", managed: false });
|
||||
bind(s, "reviewer", "reviewer-run");
|
||||
const launch = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const regen = s.call("instance.regenerate", { instance: "sec-1" }).request;
|
||||
const halted = transition(p.f.dataRoot, { business: "acme", human: p.f.doc.human, halted: true });
|
||||
assert.throws(() => s.call("instance.launch", { instance: "coder" }), (e) => e.code === "processing-halted");
|
||||
const end = s.call("instance.end", { instance: "reviewer", reason: "stuck" });
|
||||
assert.deepEqual(end.runs, ["reviewer-run"]);
|
||||
await s.launcher.tick();
|
||||
for (const request of [launch, regen]) assert.deepEqual([s.state(request).state, s.state(request).done.refusal], ["closed", "processing-halted"], request);
|
||||
assert.equal(s.state(end.request).state, "closed");
|
||||
assert.equal(await x.exited, 143);
|
||||
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run), ["reviewer-run"]);
|
||||
assert.equal(asked, 0, "a halted tick runs no thread pass");
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
transition(p.f.dataRoot, { business: "acme", human: p.f.doc.human, halted: false, generation: halted.generation });
|
||||
const after = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(after.done.refusal, null);
|
||||
assert.ok(asked > 0);
|
||||
assert.deepEqual(s.running("coder").map((r) => r.run), [after.done.run]);
|
||||
});
|
||||
|
||||
// N6 (sage-2246, DESIGN.md:118-120): an end signals the runs the verb named and the instance's runs
|
||||
// spawned by the verb's time. A run spawned after the verb survives that pass, and the next end has it.
|
||||
test("row 194 N6: a run spawned after a terminate's verb survives its pass, and the next terminate ends it", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const early = await standIn(t, p, s, { instance: "coder", run: "coder-early" });
|
||||
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
|
||||
s.call("instance.launch", { instance: "coder" });
|
||||
const first = s.call("instance.end", { instance: "coder" });
|
||||
assert.deepEqual([first.runs, first.cancelled.length], [[], 1]);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const late = await standIn(t, p, s, { instance: "coder", run: "coder-late" });
|
||||
await s.carry(first.request);
|
||||
assert.equal(await early.exited, 143);
|
||||
// The launcher's exit handler drops its own runs. A stand-in's entry goes by hand.
|
||||
s.launcher.registry.remove("acme", "coder-early");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.deepEqual([late.gone(), late.terms()], [false, 0]);
|
||||
s.call("instance.launch", { instance: "coder" });
|
||||
const second = s.call("instance.end", { instance: "coder" });
|
||||
await s.carry(second.request);
|
||||
assert.equal(await late.exited, 143);
|
||||
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run), ["coder-early", "coder-late"]);
|
||||
});
|
||||
|
||||
test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run;
|
||||
await s.claimed(run);
|
||||
await s.carry(s.call("instance.end", { instance: "sec-1" }).request);
|
||||
await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run));
|
||||
const runDir = join(p.f.dataRoot, "launches", "acme", run);
|
||||
const before = snapshot(runDir);
|
||||
const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8");
|
||||
assert.ok(Object.keys(before).length > 0);
|
||||
const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request);
|
||||
assert.equal(del.done.refusal, null);
|
||||
assert.deepEqual(snapshot(runDir), before);
|
||||
assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows");
|
||||
assert.throws(
|
||||
() => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }),
|
||||
(e) => e.code === "organization-instance-retired",
|
||||
);
|
||||
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
|
||||
});
|
||||
|
||||
test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => {
|
||||
const pa = prepare(t);
|
||||
const pb = prepare(t);
|
||||
const a = await start(t, pa);
|
||||
const b = await start(t, pb);
|
||||
b.call("instances");
|
||||
const before = snapshot(pb.root);
|
||||
const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await a.claimed(run);
|
||||
await a.carry(a.call("instance.end", { instance: "coder" }).request);
|
||||
await until(() => a.running("coder").length === 0);
|
||||
const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request);
|
||||
assert.equal(del.done.refusal, null);
|
||||
await b.launcher.tick();
|
||||
assert.deepEqual(snapshot(pb.root), before);
|
||||
assert.equal(b.launcher.registry.sessions.length, 0);
|
||||
assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true);
|
||||
});
|
||||
|
||||
// D3 item 5 and T8: the chief's session hires through its hire tool, and the same session launches
|
||||
// the new name with no restart. Its launch tool takes a name, so a name absent from the start-time
|
||||
// listing gets through to the host, which checks the file.
|
||||
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
|
||||
// only message.send and record.write and no credential.
|
||||
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
|
||||
const role = JSON.parse(readFileSync(roleFile, "utf8"));
|
||||
role.hireable = ["chief-information-security-officer"];
|
||||
writeFileSync(roleFile, JSON.stringify(role, null, 2));
|
||||
const s = await start(t, p);
|
||||
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
|
||||
await s.claimed(chief);
|
||||
const ask = async (name, args) => {
|
||||
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
|
||||
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
|
||||
};
|
||||
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
|
||||
assert.ok(hired.transaction, JSON.stringify(hired));
|
||||
const listed = s.call("instances").instances["sec-2"];
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
assert.match(launched.run, /^r[0-9a-f]{12}$/);
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
});
|
||||
@@ -451,3 +451,21 @@ test("runLoop never overlaps ticks and stops after the one in flight", async ()
|
||||
assert.ok(after >= 2);
|
||||
assert.equal(count, after);
|
||||
});
|
||||
|
||||
// Row 191: the child remains alive for recovery but sends no processing while halted.
|
||||
test('processing halt prevents notifier reads and sends and fences the wait before delivery', async t => {
|
||||
const { initializeControl, transition } = await import('../../processing-control/src/state.mjs');
|
||||
const { createProcessing } = await import('../../processing-control/src/coordinator.mjs');
|
||||
const dataRoot = tmp(t); initializeControl(dataRoot);
|
||||
const bus = broker(t);
|
||||
bus.raise('git.push.protected', { target: 'refactor', blocking: true, task_ref: 'vikunja:1/7' });
|
||||
const processing = createProcessing({ dataRoot }); t.after(() => processing.close());
|
||||
const actor = { business: 'demo', human: 'jason' }; let reads = 0, sends = 0, haltInRead = false;
|
||||
const notifier = createNotifier({ business: 'demo', dataRoot, processing, now: () => new Date(AT),
|
||||
inbox: async () => { reads++; if (haltInRead) transition(dataRoot, { ...actor, halted: true }); return bus.read('inbox'); },
|
||||
direct: { send: async () => { sends++; return { messageId: 'fixture' }; } } });
|
||||
const halted = transition(dataRoot, { ...actor, halted: true });
|
||||
await notifier.tick(); assert.equal(reads, 0); assert.equal(sends, 0);
|
||||
transition(dataRoot, { ...actor, halted: false, generation: halted.generation }); haltInRead = true;
|
||||
await notifier.tick(); assert.equal(reads, 1); assert.equal(sends, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { io, BUSINESSES } from './helpers.mjs';
|
||||
function setup(t) {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), 'processing-cli-')), calls = [];
|
||||
const processing = createProcessing({ dataRoot });
|
||||
const store = new Store(dataRoot);
|
||||
const b = new Broker({ store, businesses: BUSINESSES, processing });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const deps = { system: { dataRoot }, transport: business => async (verb, args = {}) => {
|
||||
assert.equal(business, 'demo'); calls.push(verb); return b.request(human, { verb, args });
|
||||
} };
|
||||
t.after(async () => { await processing.close(); store.close(); rmSync(dataRoot, { recursive: true, force: true }); });
|
||||
return { dataRoot, deps, calls };
|
||||
}
|
||||
|
||||
test('human halt/status/resume uses management transport without loading any channel', async t => {
|
||||
const f = setup(t), x = io();
|
||||
await main(['halt', '--business', 'demo'], x, f.deps);
|
||||
assert.match(x.out.text, /processing halted/);
|
||||
assert.ok(x.out.text.includes(f.dataRoot), 'the affected instance root is named');
|
||||
const status = io();
|
||||
await main(['processing', 'status', '--business', 'demo', '--json'], status, f.deps);
|
||||
assert.equal(JSON.parse(status.out.text).halted, true);
|
||||
const resumed = io(); await main(['resume', '--business', 'demo'], resumed, f.deps);
|
||||
assert.match(resumed.out.text, /processing resumed/);
|
||||
assert.deepEqual(f.calls, ['processing.halt', 'processing.status', 'processing.status', 'processing.resume']);
|
||||
});
|
||||
|
||||
for (const verb of ['halt', 'resume']) test(`${verb} refuses inside an agent before transport`, async t => {
|
||||
const f = setup(t), x = io({ MOSAIC_RUN_ID: 'managed-run' });
|
||||
await assert.rejects(main([verb, '--business', 'demo'], x, f.deps), e => e.exitCode === 3);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('unknown and partially stopped outcomes are never reported as a successful halt', async t => {
|
||||
const f = setup(t);
|
||||
const unknown = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], unknown, {
|
||||
...f.deps, transport: () => async () => { throw Object.assign(new Error('outcome-unknown'), { code: 'outcome-unknown' }); },
|
||||
}), e => e.exitCode === 1 && /status/.test(e.message));
|
||||
assert.doesNotMatch(unknown.out.text, /processing halted/);
|
||||
const partial = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], partial, {
|
||||
...f.deps, transport: () => async () => ({ version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 1, stopped: 0 }),
|
||||
}), e => e.exitCode === 1 && /1.*unconfirmed/.test(e.message));
|
||||
assert.doesNotMatch(partial.out.text, /all.*stopped/);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
|
||||
|
||||
@@ -50,6 +50,8 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn
|
||||
import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
||||
import { createRest } from "./rest.mjs";
|
||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||
@@ -58,7 +60,7 @@ import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
||||
import { createSetsparkApi } from "./setspark.mjs";
|
||||
import { assembleContext } from "./context.mjs";
|
||||
import { createConnector } from "./connector.mjs";
|
||||
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
||||
@@ -171,6 +173,9 @@ async function check(opts) {
|
||||
}
|
||||
|
||||
async function run(opts) {
|
||||
const root = loadDataRoot(opts.config);
|
||||
const generation = assertProcessing(root).generation;
|
||||
const processing = () => assertProcessing(root, generation);
|
||||
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
|
||||
const token = readToken(binding);
|
||||
ensureJournal(journalDir);
|
||||
@@ -180,8 +185,18 @@ async function run(opts) {
|
||||
}
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
|
||||
writePid(journalDir, process.pid);
|
||||
const cleanupPid = () => clearPid(journalDir, process.pid);
|
||||
let managed = null;
|
||||
const cleanupPid = () => {
|
||||
clearPid(journalDir, process.pid);
|
||||
if (!managed) return;
|
||||
if (processingStatus(root).halted) updateRuntime(root, managed.id, { phase: 'ended' });
|
||||
else unregisterRuntime(root, managed.id);
|
||||
};
|
||||
try {
|
||||
processing();
|
||||
managed = registerRuntime(root, { kind: 'discord', business: 'instance', run: binding.name });
|
||||
updateRuntime(root, managed.id, { ...processIdentity(process.pid), phase: 'running', group: false });
|
||||
processing();
|
||||
await runClaimed();
|
||||
} catch (err) {
|
||||
cleanupPid();
|
||||
@@ -201,6 +216,7 @@ async function run(opts) {
|
||||
|
||||
const rest = createRest({ token, log: warn });
|
||||
const gw = await rest.getGatewayBot();
|
||||
processing();
|
||||
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
|
||||
gateway.on("log", (m) => warn(`gateway: ${m}`));
|
||||
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
|
||||
@@ -208,6 +224,7 @@ async function run(opts) {
|
||||
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
|
||||
|
||||
const engine = createEngine({
|
||||
processingRoot: root, binding: binding.name,
|
||||
command: pi,
|
||||
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
|
||||
cwd: opts.repo,
|
||||
@@ -223,15 +240,17 @@ async function run(opts) {
|
||||
// key as the model's verbs; without a setspark key it has none, and an
|
||||
// approval request from the model is refused.
|
||||
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
|
||||
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, log: warn });
|
||||
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, processing, log: warn });
|
||||
let shuttingDown = false;
|
||||
let exitCode = 0;
|
||||
const shutdown = (code) => {
|
||||
let processingWatch = null;
|
||||
const shutdown = (code, immediate = false) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearInterval(processingWatch);
|
||||
exitCode = code;
|
||||
warn("stopping: finishing in-flight turns");
|
||||
connector.stop().catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
cleanupPid();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
@@ -240,8 +259,20 @@ async function run(opts) {
|
||||
warn(`gateway fatal close ${f.code}: ${f.reason}`);
|
||||
shutdown(2);
|
||||
});
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0, processingStatus(root).halted === true));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
processingWatch = setInterval(() => {
|
||||
try { processing(); }
|
||||
catch {
|
||||
const state = processingStatus(root);
|
||||
if (state.halted && !state.invalid) {
|
||||
try { requestProcessingStop(journalDir, state.generation); }
|
||||
catch { warn('processing STOP could not be recorded'); }
|
||||
}
|
||||
shutdown(BRAKE_EXIT, true);
|
||||
}
|
||||
}, 100);
|
||||
processingWatch.unref();
|
||||
|
||||
// Reload on SIGHUP: re-read the file, refuse anything that is not a
|
||||
// reloadable difference, verify new channels over REST, then swap.
|
||||
@@ -250,11 +281,13 @@ async function run(opts) {
|
||||
const reloadNow = async () => {
|
||||
const at = new Date().toISOString();
|
||||
try {
|
||||
processing();
|
||||
const next = loadBinding(bindingFile);
|
||||
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
|
||||
const diff = reloadDiff(connector.binding, next);
|
||||
for (const id of diff.channels.added) {
|
||||
const ch = await rest.getChannel(id);
|
||||
processing();
|
||||
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
|
||||
}
|
||||
connector.reload(next);
|
||||
@@ -330,6 +363,7 @@ function reloadCommand(opts) {
|
||||
|
||||
function recoverCommand(opts) {
|
||||
const dataRoot = loadDataRoot(opts.config);
|
||||
assertProcessing(dataRoot);
|
||||
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
||||
const journalDir = bindingDataDir(dataRoot, binding.name);
|
||||
ensureJournal(journalDir);
|
||||
@@ -356,7 +390,7 @@ async function main() {
|
||||
main().then((code) => {
|
||||
if (code !== null) process.exit(code);
|
||||
}).catch((err) => {
|
||||
const code = err instanceof DiscordError ? err.exitCode : 1;
|
||||
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
||||
warn(err.message);
|
||||
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
||||
process.exit(code);
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createConnector({
|
||||
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
|
||||
typingIntervalMs = 8000,
|
||||
readReceipt = READ_RECEIPT,
|
||||
processing = () => {},
|
||||
log = () => {},
|
||||
} = {}) {
|
||||
for (const [k, v] of Object.entries({ binding: initialBinding, journalDir, rest, gateway, engine })) {
|
||||
@@ -73,6 +74,22 @@ export function createConnector({
|
||||
}
|
||||
ensureJournal(journalDir);
|
||||
let binding = initialBinding;
|
||||
const guarded = object => new Proxy(object, {
|
||||
get(target, key) {
|
||||
const value = Reflect.get(target, key);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args) => { processing(); return value.apply(target, args); };
|
||||
},
|
||||
});
|
||||
rest = guarded(rest);
|
||||
if (api) api = guarded(api);
|
||||
function processingRefusal() {
|
||||
try { processing(); return null; }
|
||||
catch (e) {
|
||||
if (e.code?.startsWith('processing-')) return e.code;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null,
|
||||
@@ -140,6 +157,7 @@ export function createConnector({
|
||||
|
||||
function typingTick() {
|
||||
state.typingTimer = null;
|
||||
if (processingRefusal()) return;
|
||||
if (state.inFlight === 0 || state.typingChannel === null) return;
|
||||
rest.typing(state.typingChannel);
|
||||
state.typingTimer = setTimeoutImpl(typingTick, typingIntervalMs);
|
||||
@@ -287,6 +305,8 @@ export function createConnector({
|
||||
// is answered at once with a private line; an approval is deferred, the
|
||||
// service is asked, and the request message is then edited in place.
|
||||
async function handleInteraction(interaction) {
|
||||
const refusal = processingRefusal();
|
||||
if (refusal) return { accepted: false, reason: refusal };
|
||||
if (!interaction || typeof interaction !== "object" || typeof interaction.id !== "string") return { accepted: false, reason: "not-an-interaction" };
|
||||
if (interaction.type !== INTERACTION_TYPE.MESSAGE_COMPONENT) return { accepted: false, reason: "not-a-component" };
|
||||
if (state.interactions.has(interaction.id)) return { accepted: false, reason: "duplicate" };
|
||||
@@ -383,6 +403,7 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
record.engine.turns = result.turns ?? null;
|
||||
if (binding.tools) record.tools = result.tools || [];
|
||||
if (result.model) record.engine.model = result.model;
|
||||
processing();
|
||||
if (result.text.length === 0) throw new DiscordError("engine returned no text", 1, { code: "engine-empty" });
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: result.text, noncePrefix: message.id });
|
||||
const opened = (result.tools || []).filter((t) => t && t.name === "open_approval_request" && t.ok && t.request);
|
||||
@@ -392,10 +413,12 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
}
|
||||
} catch (err) {
|
||||
status = "failed";
|
||||
error = { code: (err.details && err.details.code) || "error", message: err.message };
|
||||
error = { code: err.code || (err.details && err.details.code) || "error", message: err.message };
|
||||
log(`turn ${message.id} failed: ${error.code}: ${error.message}`);
|
||||
// Q19: one fixed line, never model output, on failure.
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
|
||||
if (!processingRefusal()) {
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
|
||||
}
|
||||
} finally {
|
||||
state.inFlight -= 1;
|
||||
typingStop();
|
||||
@@ -436,6 +459,8 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
}
|
||||
|
||||
async function admit(message) {
|
||||
const refusal = processingRefusal();
|
||||
if (refusal) return drop(message, refusal);
|
||||
// A thread the connector has not seen: one REST lookup, only when the
|
||||
// cheap checks (guild, listed author) would let the message through.
|
||||
const listed = binding.channels.some((c) => c.id === message.channel_id);
|
||||
@@ -447,6 +472,8 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
log(`channel lookup ${message.channel_id} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const afterLookup = processingRefusal();
|
||||
if (afterLookup) return drop(message, afterLookup);
|
||||
const auth = authorize(binding, message, channelInfo);
|
||||
if (!auth.ok) return drop(message, auth.reason);
|
||||
if (typeof message.content !== "string") return drop(message, "no-content");
|
||||
@@ -567,10 +594,12 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
},
|
||||
|
||||
async start() {
|
||||
processing();
|
||||
if (state.started) throw new DiscordError("connector already started", 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
|
||||
const reconciled = await reconcile();
|
||||
const approvals = await reconcileApprovals();
|
||||
processing();
|
||||
if (approvals.length > 0) log(`approvals: ${approvals.map((a) => `${a.kind} ${a.requestId} ${a.status}`).join("; ")}`);
|
||||
state.inbox = readInboxIds(journalDir);
|
||||
state.started = true;
|
||||
@@ -582,13 +611,14 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
},
|
||||
|
||||
// Finish in-flight turns (each has its own timeout), then close.
|
||||
async stop() {
|
||||
async stop({ immediate = false } = {}) {
|
||||
state.stopping = true;
|
||||
gateway.close(1000, "stop");
|
||||
if (immediate) await engine.stop();
|
||||
await Promise.allSettled([...state.turnPromises]);
|
||||
await state.deliveryChain;
|
||||
typingStop();
|
||||
await engine.stop();
|
||||
if (!immediate) await engine.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { enabledToolNames } from "./tools.mjs";
|
||||
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
|
||||
|
||||
export const PI_FIXED_ARGS = Object.freeze([
|
||||
"--mode", "rpc", "--no-extensions", "--no-context-files", "--no-skills",
|
||||
@@ -81,6 +83,7 @@ export function createEngine({
|
||||
log = () => {},
|
||||
onExit = () => {},
|
||||
abortGraceMs = ABORT_GRACE_MS,
|
||||
processingRoot = null, binding = null,
|
||||
} = {}) {
|
||||
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
|
||||
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
|
||||
@@ -90,6 +93,18 @@ export function createEngine({
|
||||
// wedged: set when the engine gave up on pi and is stopping it. Nothing
|
||||
// is sent to that child again.
|
||||
const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null, wedged: false };
|
||||
let managed = null;
|
||||
const processing = () => {
|
||||
if (processingRoot) return assertProcessing(processingRoot, managed?.generation ?? null);
|
||||
};
|
||||
const releaseRuntime = () => {
|
||||
if (!managed) return;
|
||||
try {
|
||||
if (!processingStatus(processingRoot).halted && runtimeGroupState(runtime(processingRoot, managed.id)) === false)
|
||||
unregisterRuntime(processingRoot, managed.id);
|
||||
}
|
||||
catch { log('engine: processing receipt cleanup refused'); }
|
||||
};
|
||||
|
||||
// A turn that fails on the client side (timeout, protocol error) stays in
|
||||
// the pending queue, marked done, until pi's own turn_end for it arrives.
|
||||
@@ -282,6 +297,7 @@ export function createEngine({
|
||||
}
|
||||
|
||||
function write(command) {
|
||||
if (command.type === 'prompt') processing();
|
||||
if (!state.child || state.exited !== null || state.wedged) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
|
||||
state.child.stdin.write(JSON.stringify(command) + "\n");
|
||||
}
|
||||
@@ -301,7 +317,25 @@ export function createEngine({
|
||||
|
||||
function stopChild({ graceMs = 5000 } = {}) {
|
||||
const child = state.child;
|
||||
if (!child || state.exited !== null) return Promise.resolve(state.exited);
|
||||
if (!child || (state.exited !== null && !managed)) return Promise.resolve(state.exited);
|
||||
if (managed) {
|
||||
let record;
|
||||
try { record = runtime(processingRoot, managed.id); } catch { return Promise.resolve(state.exited); }
|
||||
const signal = createRuntimeSignaller(record, members => updateRuntime(processingRoot, managed.id, { members }));
|
||||
signal('SIGTERM');
|
||||
const began = Date.now();
|
||||
return new Promise(resolve => {
|
||||
let killed = false;
|
||||
const poll = setInterval(() => {
|
||||
let live;
|
||||
try { live = runtimeGroupState(runtime(processingRoot, managed.id)); } catch { live = false; }
|
||||
if (live === false || Date.now() - began > graceMs + 2000) {
|
||||
clearInterval(poll); releaseRuntime(); resolve(state.exited); return;
|
||||
}
|
||||
if (!killed && Date.now() - began >= graceMs) { signal('SIGKILL'); killed = true; }
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeoutImpl(() => {
|
||||
try {
|
||||
@@ -326,7 +360,23 @@ export function createEngine({
|
||||
return {
|
||||
start() {
|
||||
if (state.child) throw new DiscordError("engine already started", 1);
|
||||
const child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
processing();
|
||||
if (processingRoot) managed = registerRuntime(processingRoot, { kind: 'discord', business: 'instance', run: binding });
|
||||
let child;
|
||||
try {
|
||||
processing();
|
||||
child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], ...(processingRoot ? { detached: true } : {}) });
|
||||
if (managed) {
|
||||
const identity = processIdentity(child.pid);
|
||||
if (!identity) throw new DiscordError('engine process identity unavailable', 1);
|
||||
updateRuntime(processingRoot, managed.id, { ...identity, group: true, phase: 'running' });
|
||||
processing();
|
||||
}
|
||||
} catch (e) {
|
||||
if (child?.pid) child.kill('SIGKILL');
|
||||
releaseRuntime();
|
||||
throw e;
|
||||
}
|
||||
state.child = child;
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
@@ -342,12 +392,14 @@ export function createEngine({
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
|
||||
child.on("error", (err) => {
|
||||
releaseRuntime();
|
||||
log(`engine spawn error: ${err.message}`);
|
||||
state.exited = { code: null, signal: null, error: err.message };
|
||||
failAll("engine-down", `engine failed: ${err.message}`);
|
||||
onExit(state.exited);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
releaseRuntime();
|
||||
state.exited = { code, signal };
|
||||
failAll("engine-down", `engine exited (code ${code}, signal ${signal})`);
|
||||
onExit(state.exited);
|
||||
@@ -358,6 +410,7 @@ export function createEngine({
|
||||
// Resolves {text, message, tools, turns, usage, model, provider}. Rejects
|
||||
// with DiscordError carrying details.code for the turn record.
|
||||
prompt(text, { timeoutMs = 180000 } = {}) {
|
||||
try { processing(); } catch (e) { return Promise.reject(e); }
|
||||
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
|
||||
const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
|
||||
const done = new Promise((resolve, reject) => {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
||||
|
||||
import {
|
||||
appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -176,16 +176,64 @@ export function stopRequested(dir) {
|
||||
}
|
||||
|
||||
export function requestStop(dir, reason = "stop") {
|
||||
return stopLock(dir, () => appendStop(dir, reason));
|
||||
}
|
||||
|
||||
function stopLock(dir, action) {
|
||||
const lock = join(dir, '.stop.lock');
|
||||
try { mkdirSync(lock, { mode: 0o700 }); }
|
||||
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
|
||||
try { return action(); } finally { rmdirSync(lock); }
|
||||
}
|
||||
|
||||
function appendStop(dir, reason) {
|
||||
const path = stopPath(dir);
|
||||
const fd = openSync(path, "a", 0o600);
|
||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
function processingStops(dir) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(stopPath(dir), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > 65536) return null;
|
||||
const buffer = Buffer.alloc(65537), size = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
if (size > 65536) return null;
|
||||
const bytes = buffer.subarray(0, size);
|
||||
return new TextDecoder('utf8', { fatal: true }).decode(bytes).split('\n').filter(Boolean).map(JSON.parse);
|
||||
} catch (e) { return e.code === 'ENOENT' ? [] : null; }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
export function requestProcessingStop(dir, generation) {
|
||||
if (!PROCESSING_GENERATION.test(generation)) throw new DiscordError('invalid processing generation', 1);
|
||||
return stopLock(dir, () => {
|
||||
const rows = processingStops(dir);
|
||||
if (rows === null) throw new DiscordError('STOP cannot be verified', 1);
|
||||
const reason = `processing:${generation}`;
|
||||
if (!rows.some(r => r?.reason === reason)) appendStop(dir, reason);
|
||||
return stopPath(dir);
|
||||
});
|
||||
}
|
||||
export function clearProcessingStop(dir, generation) {
|
||||
if (!PROCESSING_GENERATION.test(generation)) return false;
|
||||
return stopLock(dir, () => {
|
||||
const rows = processingStops(dir);
|
||||
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
|
||||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
|
||||
new Date(r.at).toISOString() !== r.at)) return false;
|
||||
unlinkSync(stopPath(dir));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearStop(dir) {
|
||||
const path = stopPath(dir);
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Host-only adapters for the root coordinator. Workers cannot select these paths.
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { bindingDataDir } from './binding.mjs';
|
||||
import { requestProcessingStop, clearProcessingStop } from './journal.mjs';
|
||||
import { listRuntimes, unregisterRuntime } from '../../processing-control/src/runtimes.mjs';
|
||||
function journal(root, record) {
|
||||
const dir = bindingDataDir(root, record.run);
|
||||
for (const path of [dirname(dir), dir]) {
|
||||
const st = lstatSync(path);
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077))
|
||||
throw new Error('processing-discord-path-unsafe');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
export function discordProcessingHooks(dataRoot) {
|
||||
return {
|
||||
beforeStop(record, generation) {
|
||||
if (record.kind === 'discord') requestProcessingStop(journal(dataRoot, record), generation);
|
||||
},
|
||||
onResume(generation) {
|
||||
for (const r of listRuntimes(dataRoot)) {
|
||||
if (r.kind === 'discord' && r.haltGeneration === generation) {
|
||||
clearProcessingStop(journal(dataRoot, r), generation);
|
||||
unregisterRuntime(dataRoot, r.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { makeRoot } from './helpers.mjs';
|
||||
import { ensureJournal, requestStop, stopRequested, stopPath } from '../src/journal.mjs';
|
||||
const api = await import('../src/journal.mjs');
|
||||
function fixture() { const dir = join(makeRoot(), 'journal'); ensureJournal(dir); return dir; }
|
||||
test('resume clears only its own generation-tagged Discord STOP and keeps manual or newer brakes', () => {
|
||||
assert.equal(typeof api.requestProcessingStop, 'function');
|
||||
const dir = fixture(), generation = randomUUID();
|
||||
api.requestProcessingStop(dir, generation); const first = readFileSync(stopPath(dir));
|
||||
api.requestProcessingStop(dir, generation); assert.deepEqual(readFileSync(stopPath(dir)), first);
|
||||
assert.equal(api.clearProcessingStop(dir, randomUUID()), false);
|
||||
assert.equal(api.clearProcessingStop(dir, generation), true); assert.equal(stopRequested(dir), false);
|
||||
api.requestProcessingStop(dir, generation); requestStop(dir, 'cli stop');
|
||||
const mixed = readFileSync(stopPath(dir));
|
||||
assert.equal(api.clearProcessingStop(dir, generation), false); assert.deepEqual(readFileSync(stopPath(dir)), mixed);
|
||||
});
|
||||
test('unreadable, malformed and symlink STOP never get removed or followed by processing recovery', () => {
|
||||
assert.equal(typeof api.requestProcessingStop, 'function');
|
||||
const dir = fixture(), generation = randomUUID();
|
||||
writeFileSync(stopPath(dir), 'unreadable operator brake\n', { mode: 0o600 });
|
||||
assert.equal(api.clearProcessingStop(dir, generation), false);
|
||||
unlinkSync(stopPath(dir)); const outside = join(dir, 'outside');
|
||||
writeFileSync(outside, 'untouched', { mode: 0o600 }); symlinkSync(outside, stopPath(dir));
|
||||
assert.equal(api.clearProcessingStop(dir, generation), false);
|
||||
assert.throws(() => api.requestProcessingStop(dir, generation));
|
||||
assert.equal(readFileSync(outside, 'utf8'), 'untouched');
|
||||
});
|
||||
|
||||
test('coordinator halt writes the Discord brake and resume clears only its own stopped runtime', async () => {
|
||||
const { initializeControl } = await import('../../processing-control/src/state.mjs');
|
||||
const { registerRuntime, updateRuntime } = await import('../../processing-control/src/runtimes.mjs');
|
||||
const { createProcessing } = await import('../../processing-control/src/coordinator.mjs');
|
||||
const { discordProcessingHooks } = await import('../src/processing.mjs');
|
||||
const root = makeRoot(); initializeControl(root);
|
||||
const dir = join(root, 'discord', 'test-seat'); ensureJournal(dir);
|
||||
const processing = createProcessing({ dataRoot: root, ...discordProcessingHooks(root) });
|
||||
const first = registerRuntime(root, { kind: 'discord', business: 'instance', run: 'test-seat' });
|
||||
updateRuntime(root, first.id, { phase: 'ended' });
|
||||
const halted = await processing.halt({ business: 'demo', human: 'jason' });
|
||||
assert.equal(halted.quiescent, true); assert.equal(stopRequested(dir), true);
|
||||
await processing.resume({ business: 'demo', human: 'jason', generation: halted.generation });
|
||||
assert.equal(stopRequested(dir), false);
|
||||
const second = registerRuntime(root, { kind: 'discord', business: 'instance', run: 'test-seat' });
|
||||
updateRuntime(root, second.id, { phase: 'ended' });
|
||||
const again = await processing.halt({ business: 'demo', human: 'jason' });
|
||||
requestStop(dir, 'cli stop'); const bytes = readFileSync(stopPath(dir));
|
||||
await processing.resume({ business: 'demo', human: 'jason', generation: again.generation });
|
||||
assert.deepEqual(readFileSync(stopPath(dir)), bytes);
|
||||
await processing.close();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createConnector } from '../src/connector.mjs';
|
||||
import { initializeControl, assertProcessing, transition } from '../../processing-control/src/state.mjs';
|
||||
import { makeRoot, makeRepo, makeDeployment, binding, message, fakeRest, fakeGateway, fakeEngine } from './helpers.mjs';
|
||||
import { appendOutbox, ensureJournal, readOutbox, listTurns } from '../src/journal.mjs';
|
||||
const actor = { business: 'demo', human: 'jason', halted: true };
|
||||
function fixture() {
|
||||
const root = makeRoot(); initializeControl(root);
|
||||
const journalDir = join(root, 'journal'), rest = fakeRest(), gateway = fakeGateway(), engine = fakeEngine({ replies: [{ text: 'reply' }] });
|
||||
const processing = () => assertProcessing(root);
|
||||
const options = { binding: binding(), journalDir, rest, gateway, engine, processing };
|
||||
return { root, journalDir, rest, gateway, engine, options };
|
||||
}
|
||||
test('halted connector refuses before outbox reconciliation, engine or gateway start', async () => {
|
||||
const f = fixture(); ensureJournal(f.journalDir);
|
||||
appendOutbox(f.journalDir, { nonce: 'pending', channelId: '100000000000000200', status: 'intent', content: 'do not replay', at: new Date().toISOString() });
|
||||
transition(f.root, actor);
|
||||
await assert.rejects(createConnector(f.options).start(), e => e.code === 'processing-halted');
|
||||
assert.equal(f.rest.calls.length, 0); assert.equal(f.engine.started, false);
|
||||
const corrupt = fixture(); ensureJournal(corrupt.journalDir);
|
||||
const connector = createConnector(corrupt.options);
|
||||
transition(corrupt.root, actor);
|
||||
writeFileSync(join(corrupt.journalDir, 'outbox.jsonl'), '{broken\n', { mode: 0o600 });
|
||||
await assert.rejects(connector.start(), e => e.code === 'processing-halted');
|
||||
assert.equal(corrupt.rest.calls.length, 0); assert.equal(corrupt.engine.started, false);
|
||||
});
|
||||
test('halt arriving during startup reconciliation prevents engine and gateway start', async () => {
|
||||
const f = fixture(); ensureJournal(f.journalDir);
|
||||
appendOutbox(f.journalDir, { nonce: 'pending', channelId: '100000000000000200', status: 'intent', content: 'first', at: new Date().toISOString() });
|
||||
const create = f.rest.createMessage;
|
||||
f.rest.createMessage = async (...args) => { const result = await create(...args); transition(f.root, actor); return result; };
|
||||
await assert.rejects(createConnector(f.options).start(), e => e.code === 'processing-halted');
|
||||
assert.equal(f.engine.started, false);
|
||||
});
|
||||
test('halted connector refuses a new inbound turn before claiming inbox or invoking the engine', async () => {
|
||||
const f = fixture(), connector = createConnector(f.options); await connector.start();
|
||||
transition(f.root, actor);
|
||||
const answer = await connector.handleMessage(message());
|
||||
assert.equal(answer.accepted, false); assert.equal(answer.reason, 'processing-halted');
|
||||
assert.equal(connector.inboxSize, 0); assert.equal(f.engine.prompts.length, 0);
|
||||
await connector.stop();
|
||||
});
|
||||
test('halt during a model turn never sends its answer or a fallback failure message', async () => {
|
||||
const f = fixture();
|
||||
f.engine.prompt = async () => { transition(f.root, actor); return { text: 'must not send', usage: { input: 1, output: 1 } }; };
|
||||
const connector = createConnector(f.options); await connector.start();
|
||||
const turn = await connector.handleMessage(message()); await turn.turn;
|
||||
assert.equal(f.rest.calls.length, 0);
|
||||
assert.equal(listTurns(f.journalDir)[0].error.code, 'processing-halted');
|
||||
await connector.stop();
|
||||
});
|
||||
test('Discord run and recover refuse a halted root before reading a token or reaching network', () => {
|
||||
const root = makeRoot(), repo = makeRepo(root), d = makeDeployment(root);
|
||||
mkdirSync(d.dataRoot, { recursive: true, mode: 0o700 }); initializeControl(d.dataRoot);
|
||||
transition(d.dataRoot, actor); unlinkSync(d.tokenFile);
|
||||
for (const command of ['run', 'recover']) {
|
||||
const r = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, command, d.raw.name, '--config', d.config, '--repo', repo], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /processing-halted/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Discord engine publishes a verified group and a root halt kills it independently of the connector', async t => {
|
||||
const { createEngine } = await import('../src/engine-pi.mjs');
|
||||
const { listRuntimes, sweepRuntimes, processIdentity } = await import('../../processing-control/src/runtimes.mjs');
|
||||
const f = fixture();
|
||||
const engine = createEngine({ command: process.execPath,
|
||||
args: [new URL('./fake-pi.mjs', import.meta.url).pathname], cwd: f.root,
|
||||
env: { ...process.env }, processingRoot: f.root, binding: 'test-seat' });
|
||||
t.after(() => engine.stop({ graceMs: 50 }));
|
||||
const child = engine.start();
|
||||
const tracked = listRuntimes(f.root);
|
||||
assert.equal(tracked.length, 1); assert.equal(tracked[0].pid, child.pid);
|
||||
assert.equal(tracked[0].kind, 'discord'); assert.equal(tracked[0].group, true);
|
||||
const halted = transition(f.root, actor);
|
||||
await assert.rejects(engine.prompt('not admitted'), e => e.code === 'processing-halted');
|
||||
const result = await sweepRuntimes(f.root, halted.generation, { graceMs: 80, killWaitMs: 1000 });
|
||||
assert.equal(result.quiescent, true); assert.equal(processIdentity(child.pid), null);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
||||
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
||||
@@ -113,9 +113,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
decision: COMMON.decision,
|
||||
}, ["task_ref", "roles"]));
|
||||
}
|
||||
// Row 194 (#1692, D3 item 5): the instance is a name, not an enum fixed when the bundle was built,
|
||||
// so the chief launches its own hire without a restart. The host checks the listing at each launch.
|
||||
if (resolved.launch) {
|
||||
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
||||
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
|
||||
add("launch", "launch", "role.launch", `Launch a managed session for a role instance the organization lists for you to launch. When this session started they were: ${resolved.launch.instances.join(", ")}. An instance you hired since is listed too. The host refuses an instance that isn't listed, one that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
||||
instance: str("Role instance to launch", { pattern: ID_PATTERN.source }),
|
||||
}, ["instance"]));
|
||||
}
|
||||
if (tracker) {
|
||||
@@ -170,6 +172,22 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["role", "reason"]));
|
||||
}
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
|
||||
// broker checks the definition against the role files and the veto on every call.
|
||||
if (held.has("role.hire")) {
|
||||
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
|
||||
name: str("The new instance's name"),
|
||||
definition: str("The role definition it runs"),
|
||||
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
|
||||
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "definition", "reason"]));
|
||||
}
|
||||
if (held.has("role.fire")) {
|
||||
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
|
||||
name: str("Role instance to fire"),
|
||||
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "reason"]));
|
||||
}
|
||||
if (held.has("organization.change")) {
|
||||
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
|
||||
// Row 155 (#1649, Q169): assistants configures the assistant.
|
||||
|
||||
@@ -97,22 +97,23 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
const b = build(t, r);
|
||||
const prompt = readFileSync(b.files.prompt, "utf8");
|
||||
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.ok(b.manifest.typed.includes("launch"));
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
|
||||
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
|
||||
// the chief holds is untyped and the prompt has no untyped line.
|
||||
// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3)
|
||||
// granted role.hire and role.fire, with hire and fire.
|
||||
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, [], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
|
||||
import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
|
||||
import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs";
|
||||
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const names = (tools) => tools.map((t) => t.name);
|
||||
@@ -37,19 +38,47 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
});
|
||||
|
||||
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
|
||||
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
|
||||
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
|
||||
// Row 194 (#1692, D3 item 5): a name with the id pattern, not an enum fixed at bundle time, so a hire
|
||||
// launches with no restart. The start-time listing stays in the description, and the host checks it.
|
||||
assert.deepEqual(launch.parameters.properties.instance, { type: "string", description: "Role instance to launch", pattern: ID_PATTERN.source });
|
||||
assert.match(launch.description, /When this session started they were: coder, reviewer\. An instance you hired since is listed too\. The host refuses an instance that isn't listed,/);
|
||||
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692,
|
||||
// PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire.
|
||||
assert.deepEqual(untypedActions(chief, tools), []);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
|
||||
// and only a holder of the grant gets them.
|
||||
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
|
||||
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
|
||||
const hire = tools.find((x) => x.name === "hire");
|
||||
const fire = tools.find((x) => x.name === "fire");
|
||||
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
|
||||
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
|
||||
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
|
||||
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
|
||||
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
|
||||
for (const tool of [hire, fire]) {
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
|
||||
}
|
||||
for (const instance of ["coder", "reviewer", "pm"])
|
||||
for (const name of ["hire", "fire"]) assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes(name), `${instance} ${name}`);
|
||||
// Each tool follows its own grant.
|
||||
const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] }));
|
||||
const HIRE_FIRE = (n) => n === "hire" || n === "fire";
|
||||
assert.deepEqual(names(typedTools({ resolved: only("role.hire") })).filter(HIRE_FIRE), ["hire"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: only("role.fire") })).filter(HIRE_FIRE), ["fire"]);
|
||||
});
|
||||
|
||||
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
|
||||
// chief of staff and every other role has no such tool, and the chief's launch offer is unchanged.
|
||||
test("request_staff is the pm's alone: staff.request over message.send, roles and counts bounded", (t) => {
|
||||
@@ -75,7 +104,8 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an
|
||||
assert.ok(!names(tools).includes("launch"));
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const launch = typedTools({ resolved: chief, tracker: true }).find((x) => x.name === "launch");
|
||||
assert.deepEqual(launch.parameters.properties.instance.enum, chief.launch.instances);
|
||||
assert.equal(launch.parameters.properties.instance.enum, undefined);
|
||||
assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`));
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Instance processing control
|
||||
|
||||
The durable switch is `<dataRoot>/control/processing.json`, using the root resolved
|
||||
by the existing system configuration loader. All businesses in that root share
|
||||
one switch; separate roots remain independent. No package selects an instance by
|
||||
business name or systemd unit name.
|
||||
|
||||
A human runs `mosaic halt [--business ID]`, `mosaic processing status --json` and
|
||||
`mosaic resume [--business ID]`. The business chooses the human proof context.
|
||||
Halt publishes state before waiting for stops. Resume checks the displayed
|
||||
generation and refuses while a stop is unresolved. Neither agents nor tester
|
||||
sessions can change the switch. CLI channel settings cannot disable recovery.
|
||||
|
||||
The bus remains available for reads and human management. Managed processing,
|
||||
new sessions, task containers, tracker activity and outbound notification stop.
|
||||
Native diagnostic/development seats remain available. A remote request already
|
||||
accepted cannot be undone: its completion or unknown outcome remains evidence.
|
||||
No task is automatically retried on resume.
|
||||
|
||||
The trusted host explicitly initializes the first off document. Once control/
|
||||
exists, a missing, malformed, unsafe or oversized state refuses work. Recovery
|
||||
is halt, then resume; unsafe parent ownership/path boundaries require operator
|
||||
repair. Never delete the file to clear a halt. State publication uses exclusive
|
||||
temporary files, fsync and atomic rename. Run evidence remains write-once.
|
||||
|
||||
Runtime receipts bind generated ids to the canonical root, boot id and process
|
||||
start ticks or inspected container id and labels. Stops begin concurrently,
|
||||
TERM first, then KILL after five seconds, with two seconds for observation.
|
||||
Unknown remains unknown and prevents resume. Stop receipts are write-once and
|
||||
mirrored into reserved bus actions. An interrupted Docker CLI is not evidence
|
||||
that its container stopped; a container that cannot be inspected stays unknown.
|
||||
|
||||
Task containers mount the whole control directory read-only, below the existing
|
||||
root mount. The image entrypoint uses the shared strict reader and admission
|
||||
generation. A row-191 upgrade must stop old processing before the first trusted
|
||||
initialization and align the image, bus and copy in a separately approved window.
|
||||
Nothing in a code build performs that activation.
|
||||
|
||||
The Console status and persistent halted banner work read-only. The controls
|
||||
require the existing authenticated human write path and `--send`; during Q175's
|
||||
interim the UI explains that the instance CLI is the working path. No write
|
||||
exception or alternate authentication scheme is introduced.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/processing-control",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "test": "node --test tests/*.test.mjs" }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
// The shell launchers already load/release/bootstrap. Resolve the selected config again here.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadSystem } from '../../cli/src/config.mjs';
|
||||
import { runContainer } from './containers.mjs';
|
||||
import { unregisterRuntime } from './runtimes.mjs';
|
||||
import { initializeControl, assertProcessing } from './state.mjs';
|
||||
try {
|
||||
const system = loadSystem();
|
||||
initializeControl(system.dataRoot);
|
||||
assertProcessing(system.dataRoot);
|
||||
const args = process.argv.slice(2), tty = args[0] === '--tty';
|
||||
if (args.length === 1 && args[0] === '--check') process.exit(0);
|
||||
if (tty) args.shift();
|
||||
const result = runContainer(system.dataRoot, { kind: 'container', business: 'instance', run: randomUUID() },
|
||||
{ tty, args, env: process.env, stdio: 'inherit' });
|
||||
if (!result.haltGeneration && !result.proc.error && result.proc.status === 0) unregisterRuntime(system.dataRoot, result.managed.id);
|
||||
if (result.proc.error) throw result.proc.error;
|
||||
process.exit(result.proc.status ?? 1);
|
||||
} catch (e) {
|
||||
process.stderr.write(`processing: ${e.code?.startsWith('processing-') ? e.code : 'container-launch-failed'}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawnSync, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { initializeControl, assertProcessing } from './state.mjs';
|
||||
import { registerRuntime, updateRuntime, runtime, rootTag } from './runtimes.mjs';
|
||||
const execute = promisify(execFile);
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export function prepareContainer(dataRoot, record) {
|
||||
initializeControl(dataRoot);
|
||||
const state = assertProcessing(dataRoot);
|
||||
const containerName = `mosaic-${rootTag(dataRoot).slice(0, 12)}-${randomUUID()}`;
|
||||
const managed = registerRuntime(dataRoot, { ...record, containerName });
|
||||
assertProcessing(dataRoot, state.generation);
|
||||
return managed;
|
||||
}
|
||||
export function containerArgs(record, { tty = false, args = [] } = {}) {
|
||||
return ['compose', 'run', '--rm', ...(!tty ? ['-T'] : []), '--name', record.containerName,
|
||||
'--label', `mosaic.root=${record.root}`, '--label', `mosaic.runtime=${record.id}`,
|
||||
'-e', `MOSAIC_PROCESSING_GENERATION=${record.generation}`, 'mosaic-agent', ...args];
|
||||
}
|
||||
export function runContainer(dataRoot, record, { tty = false, args = [], ...options } = {}) {
|
||||
const managed = prepareContainer(dataRoot, record);
|
||||
assertProcessing(dataRoot, managed.generation);
|
||||
// Keep this synchronous. The independent broker sweeps the prepublished name while this waits.
|
||||
const proc = spawnSync('docker', containerArgs(managed, { tty, args }), options);
|
||||
const current = runtime(dataRoot, managed.id);
|
||||
// An interrupted Docker client is not proof its container exited. Keep that receipt for inspection.
|
||||
if (!proc.error && proc.status === 0) updateRuntime(dataRoot, managed.id, { phase: 'ended' });
|
||||
return { proc, managed, haltGeneration: current.haltGeneration ?? null };
|
||||
}
|
||||
export function createContainerStopper({ dataRoot = null, docker = async args => {
|
||||
const r = await execute('docker', args, { encoding: 'utf8', timeout: 2000, maxBuffer: 131072 });
|
||||
return r.stdout;
|
||||
} } = {}) {
|
||||
return async (record, { graceMs = 5000, killWaitMs = 2000 } = {}) => {
|
||||
const signals = [];
|
||||
let id = record.containerId ?? null, seen = false;
|
||||
const start = Date.now(), termEnd = start + graceMs, end = termEnd + killWaitMs;
|
||||
async function inspect(target) {
|
||||
const doc = JSON.parse(await docker(['inspect', '--format', '{{json .}}', target]));
|
||||
if (!doc || !/^[a-f0-9]{64}$/.test(doc.Id) || doc.Name !== '/' + record.containerName ||
|
||||
doc.Config?.Labels?.['mosaic.root'] !== record.root || doc.Config?.Labels?.['mosaic.runtime'] !== record.id ||
|
||||
typeof doc.State?.Running !== 'boolean' || (id && doc.Id !== id)) {
|
||||
const error = new Error('container identity mismatch'); error.code = 'identity-mismatch'; throw error;
|
||||
}
|
||||
if (!id) {
|
||||
id = doc.Id;
|
||||
if (dataRoot) updateRuntime(dataRoot, record.id, { containerId: id });
|
||||
}
|
||||
seen = true;
|
||||
return doc;
|
||||
}
|
||||
while (Date.now() <= end) {
|
||||
try {
|
||||
const doc = await inspect(id ?? record.containerName);
|
||||
if (!doc.State.Running) return { outcome: signals.includes('SIGKILL') ? 'killed' : signals.length ? 'terminated' : 'already-exited', signals };
|
||||
const next = !signals.length ? 'TERM' : Date.now() >= termEnd && !signals.includes('SIGKILL') ? 'KILL' : null;
|
||||
if (next) {
|
||||
// Revalidate immediately before each signal, targeting the immutable inspected id.
|
||||
const checked = await inspect(id);
|
||||
if (!checked.State.Running) continue;
|
||||
await docker(['kill', '--signal', next, id]);
|
||||
signals.push('SIG' + next);
|
||||
}
|
||||
} catch (e) {
|
||||
// A missing name can still be a queued create. Only a container observed before a signal
|
||||
// and then removed by --rm is known to have finished. Never signal on an inspect failure.
|
||||
if (e.code === 'identity-mismatch') return { outcome: 'unknown', signals };
|
||||
if (id && seen && signals.length) {
|
||||
try {
|
||||
const listed = await docker(['ps', '--all', '--no-trunc', '--filter', `id=${id}`, '--format', '{{.ID}}']);
|
||||
if (listed.trim() === '') return { outcome: signals.includes('SIGKILL') ? 'killed' : 'terminated', signals };
|
||||
} catch { /* a failed Docker request proves nothing about the workload */ }
|
||||
}
|
||||
if (dataRoot) {
|
||||
try {
|
||||
if (runtime(dataRoot, record.id).phase === 'ended')
|
||||
return { outcome: signals.includes('SIGKILL') ? 'killed' : signals.length ? 'terminated' : 'already-exited', signals };
|
||||
} catch { return { outcome: 'unknown', signals }; }
|
||||
}
|
||||
}
|
||||
await pause(Math.min(20, Math.max(1, end - Date.now())));
|
||||
}
|
||||
return { outcome: 'unknown', signals };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { initializeControl, processingStatus, transition, ProcessingError } from './state.mjs';
|
||||
import { readStops, sweepRuntimes } from './runtimes.mjs';
|
||||
import { withProcessing, currentProcessing } from './effects.mjs';
|
||||
|
||||
// The control bus stays reachable. Only its processing, supervised workloads and background effects stop.
|
||||
export function createProcessing({ dataRoot, graceMs = 5000, killWaitMs = 2000, containerStop = null,
|
||||
onTransition = () => {}, onStop = () => {}, beforeStop = () => {}, onResume = () => {}, log = () => {} }) {
|
||||
try { initializeControl(dataRoot); } catch (e) {
|
||||
if (!e.code?.startsWith('processing-')) throw e;
|
||||
}
|
||||
let active = null, timer = null, closed = false;
|
||||
let last = { quiescent: true, remaining: 0, stopped: 0 };
|
||||
let settledGeneration = null;
|
||||
const read = () => {
|
||||
const state = processingStatus(dataRoot);
|
||||
const counts = state.invalid || (state.halted && state.generation !== settledGeneration)
|
||||
? { quiescent: false, remaining: null, stopped: 0 } : last;
|
||||
return { ...state, ...counts, stopping: active !== null };
|
||||
};
|
||||
function reconcile() {
|
||||
if (active) return active;
|
||||
const state = processingStatus(dataRoot);
|
||||
if (state.invalid) return Promise.resolve({ quiescent: false, remaining: null, stopped: 0 });
|
||||
active = Promise.resolve().then(async () => {
|
||||
await onTransition(state);
|
||||
if (state.halted) {
|
||||
const result = await sweepRuntimes(dataRoot, state.generation, { graceMs, killWaitMs, containerStop, beforeStop });
|
||||
for (const stop of readStops(dataRoot, state.generation)) await onStop(stop);
|
||||
const { results, ...summary } = result;
|
||||
last = summary;
|
||||
settledGeneration = state.generation;
|
||||
} else last = { quiescent: true, remaining: 0, stopped: 0 };
|
||||
return last;
|
||||
}).finally(() => { active = null; });
|
||||
return active;
|
||||
}
|
||||
return {
|
||||
read,
|
||||
work: fn => withProcessing(dataRoot, fn),
|
||||
guard: () => currentProcessing()?.guard(),
|
||||
signal: () => currentProcessing()?.signal,
|
||||
stopReceipts: generation => readStops(dataRoot, generation),
|
||||
// Publication is synchronous: no timer/model/HTTP wait can delay the admission fence.
|
||||
halt(actor) {
|
||||
const published = transition(dataRoot, { ...actor, halted: true });
|
||||
return reconcile().then(summary => ({ ...published, ...summary }));
|
||||
},
|
||||
async resume(actor) {
|
||||
if (active) throw new ProcessingError('processing-stop-pending');
|
||||
const state = processingStatus(dataRoot);
|
||||
if (state.invalid) throw new ProcessingError(state.code);
|
||||
if (state.generation !== actor.generation) throw new ProcessingError('processing-generation-changed');
|
||||
const summary = await reconcile();
|
||||
if (!summary.quiescent) throw new ProcessingError('processing-stop-pending');
|
||||
await onResume(state.generation);
|
||||
const published = transition(dataRoot, { ...actor, halted: false });
|
||||
await onTransition(published);
|
||||
last = { quiescent: true, remaining: 0, stopped: 0 };
|
||||
return { ...published, ...last };
|
||||
},
|
||||
reconcile,
|
||||
start() {
|
||||
if (timer || closed) return;
|
||||
const tick = () => {
|
||||
if (!active && processingStatus(dataRoot).halted) reconcile().catch(() => log('processing-reconcile-failed'));
|
||||
};
|
||||
tick(); timer = setInterval(tick, 250); timer.unref();
|
||||
},
|
||||
async close() {
|
||||
closed = true; clearInterval(timer); timer = null;
|
||||
await active;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { assertProcessing } from './state.mjs';
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, createRuntimeSignaller, runtimeGroupState, runtime } from './runtimes.mjs';
|
||||
const contexts = new AsyncLocalStorage();
|
||||
export const currentProcessing = () => contexts.getStore();
|
||||
// Trusted compensation/temporary-file cleanup only; never an agent work entry point.
|
||||
export const withoutProcessing = fn => contexts.run(undefined, fn);
|
||||
export async function withProcessing(dataRoot, fn) {
|
||||
const parent = currentProcessing();
|
||||
const generation = assertProcessing(dataRoot, parent?.dataRoot === dataRoot ? parent.generation : null).generation;
|
||||
const pending = registerRuntime(dataRoot, { kind: 'effect', business: 'instance', run: randomUUID() });
|
||||
const controller = new AbortController();
|
||||
const children = [];
|
||||
const guard = () => assertProcessing(dataRoot, generation);
|
||||
const poll = setInterval(() => {
|
||||
try { guard(); } catch (e) { controller.abort(e); }
|
||||
}, 25);
|
||||
poll.unref();
|
||||
const scope = {
|
||||
dataRoot, generation, signal: controller.signal, guard,
|
||||
track(child) {
|
||||
const identity = processIdentity(child.pid);
|
||||
if (!identity) return;
|
||||
children.push(new Promise(resolve => child.once('close', resolve)));
|
||||
let record;
|
||||
try { record = registerRuntime(dataRoot, { kind: 'effect', business: 'instance', run: randomUUID() }); }
|
||||
catch (e) {
|
||||
createRuntimeSignaller({ ...identity, kind: 'effect', group: true })('SIGKILL');
|
||||
throw e;
|
||||
}
|
||||
const running = updateRuntime(dataRoot, record.id, { ...identity, group: true, phase: 'running' });
|
||||
const signal = createRuntimeSignaller(running, members => updateRuntime(dataRoot, record.id, { members }));
|
||||
let kill = null;
|
||||
const cancel = () => {
|
||||
signal('SIGTERM');
|
||||
kill = setTimeout(() => signal('SIGKILL'), 5000);
|
||||
kill.unref();
|
||||
};
|
||||
controller.signal.addEventListener('abort', cancel, { once: true });
|
||||
child.once('close', () => {
|
||||
controller.signal.removeEventListener('abort', cancel); clearTimeout(kill);
|
||||
try {
|
||||
if (runtimeGroupState(runtime(dataRoot, record.id)) === false) unregisterRuntime(dataRoot, record.id);
|
||||
} catch { /* keep unverifiable receipts for reconciliation */ }
|
||||
});
|
||||
try { guard(); } catch (e) { controller.abort(e); }
|
||||
if (controller.signal.aborted && kill === null) cancel();
|
||||
},
|
||||
};
|
||||
try {
|
||||
guard();
|
||||
const result = await contexts.run(scope, () => fn(scope));
|
||||
guard();
|
||||
return result;
|
||||
} finally {
|
||||
clearInterval(poll);
|
||||
await Promise.all(children);
|
||||
unregisterRuntime(dataRoot, pending.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Image-baked final admission, using the same strict state reader as the host.
|
||||
import { assertProcessing } from './state.mjs';
|
||||
try { assertProcessing(process.argv[2], process.env.MOSAIC_PROCESSING_GENERATION ?? null); }
|
||||
catch (e) {
|
||||
process.stderr.write(`run-agent: ${e.code?.startsWith('processing-') ? e.code : 'processing-state-invalid'}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Only host-side starters publish these identities. A receipt is never a request from a worker.
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, linkSync,
|
||||
readdirSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { controlDirectory, readControl, assertProcessing, ProcessingError } from './state.mjs';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@/-]{0,159}$/;
|
||||
const fail = () => { throw new ProcessingError('processing-runtime-invalid'); };
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export const rootTag = root => createHash('sha256').update(dirname(controlDirectory(root))).digest('hex');
|
||||
export function processIdentity(pid) {
|
||||
try {
|
||||
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
const fields = raw.slice(raw.lastIndexOf(')') + 2).split(' ');
|
||||
if (fields[0] === 'Z') return null;
|
||||
return { pid, startTime: fields[19], boot: readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() };
|
||||
} catch { return null; }
|
||||
}
|
||||
function alive(identity) {
|
||||
if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 2 ||
|
||||
typeof identity.startTime !== 'string' || !UUID.test(identity.boot)) return null;
|
||||
const current = processIdentity(identity.pid);
|
||||
if (current) return current.boot === identity.boot && current.startTime === identity.startTime;
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${identity.pid}/stat`, 'utf8');
|
||||
if (stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0] === 'Z') return false;
|
||||
process.kill(identity.pid, 0); return null;
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code === 'ESRCH') return false;
|
||||
if (e.code === 'ENOENT') {
|
||||
try { process.kill(identity.pid, 0); } catch (gone) { if (gone.code === 'ESRCH') return false; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function directory(root, ...segments) {
|
||||
// Checking the state also validates the service-owned control parent. A halted state is fine here.
|
||||
readControl(root);
|
||||
let dir = controlDirectory(root);
|
||||
for (const name of segments) {
|
||||
if (!['runtimes', 'stops', 'run-stops'].includes(name) && !UUID.test(name)) fail();
|
||||
const parent = dir;
|
||||
dir = join(dir, name);
|
||||
let made = false;
|
||||
try { mkdirSync(dir, { mode: 0o700 }); made = true; }
|
||||
catch (e) { if (e.code !== 'EEXIST') fail(); }
|
||||
const st = lstatSync(dir);
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o700) fail();
|
||||
if (made) {
|
||||
const fd = openSync(parent, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { fsyncSync(fd); } finally { closeSync(fd); }
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
function read(file) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > 16384) fail();
|
||||
const buffer = Buffer.alloc(16385), n = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
if (n > 16384) fail();
|
||||
return JSON.parse(new TextDecoder('utf8', { fatal: true }).decode(buffer.subarray(0, n)));
|
||||
} catch { fail(); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
function write(file, doc, once = false) {
|
||||
let fd, dirfd;
|
||||
const temp = join(dirname(file), `.receipt-${randomUUID()}`);
|
||||
try {
|
||||
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeFileSync(fd, JSON.stringify(doc) + '\n'); fsyncSync(fd); closeSync(fd); fd = undefined;
|
||||
if (once) linkSync(temp, file);
|
||||
else renameSync(temp, file);
|
||||
dirfd = openSync(dirname(file), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
fsyncSync(dirfd);
|
||||
return doc;
|
||||
} catch (e) {
|
||||
if (once && e.code === 'EEXIST') return read(file);
|
||||
fail();
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
if (dirfd !== undefined) closeSync(dirfd);
|
||||
try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') fail(); }
|
||||
}
|
||||
}
|
||||
const fields = ['version', 'id', 'root', 'kind', 'business', 'run', 'generation', 'phase', 'supervisor',
|
||||
'pid', 'startTime', 'boot', 'group', 'runnerPid', 'runnerStartTime', 'containerName', 'containerId', 'haltGeneration', 'members'];
|
||||
function validate(r) {
|
||||
if (!r || typeof r !== 'object' || Array.isArray(r) || Object.keys(r).some(k => !fields.includes(k)) ||
|
||||
r.version !== 1 || !UUID.test(r.id) || !/^[0-9a-f]{64}$/.test(r.root) ||
|
||||
!['session', 'task', 'container', 'discord', 'effect'].includes(r.kind) ||
|
||||
!UUID.test(r.generation) || !['starting', 'running', 'ended'].includes(r.phase) ||
|
||||
typeof r.business !== 'string' || !ID.test(r.business) || typeof r.run !== 'string' || !ID.test(r.run) ||
|
||||
!r.supervisor || !Number.isSafeInteger(r.supervisor.pid) || !UUID.test(r.supervisor.boot)) fail();
|
||||
if (r.pid !== undefined && (!Number.isSafeInteger(r.pid) || r.pid < 2 ||
|
||||
typeof r.startTime !== 'string' || !/^\d+$/.test(r.startTime) || !UUID.test(r.boot))) fail();
|
||||
if (r.group !== undefined && typeof r.group !== 'boolean') fail();
|
||||
if (r.runnerPid !== undefined && (!Number.isSafeInteger(r.runnerPid) || r.runnerPid < 2 ||
|
||||
typeof r.runnerStartTime !== 'string' || !/^\d+$/.test(r.runnerStartTime))) fail();
|
||||
if (r.haltGeneration !== undefined && !UUID.test(r.haltGeneration)) fail();
|
||||
if (r.members !== undefined && (!r.group || !Array.isArray(r.members) || r.members.length > 64 ||
|
||||
r.members.some(m => !m || Object.keys(m).sort().join(',') !== 'boot,pid,startTime' ||
|
||||
!Number.isSafeInteger(m.pid) || m.pid < 2 || typeof m.startTime !== 'string' ||
|
||||
!/^\d+$/.test(m.startTime) || m.boot !== r.boot))) fail();
|
||||
if (r.containerName !== undefined && !/^mosaic-[a-f0-9]{12}-[a-f0-9-]{36}$/.test(r.containerName)) fail();
|
||||
if (r.containerId !== undefined && !/^[a-f0-9]{64}$/.test(r.containerId)) fail();
|
||||
return r;
|
||||
}
|
||||
export function registerRuntime(root, record) {
|
||||
const admission = assertProcessing(root);
|
||||
const r = validate({ ...record, version: 1, id: randomUUID(), root: rootTag(root),
|
||||
generation: admission.generation, phase: 'starting', supervisor: processIdentity(process.pid) });
|
||||
const file = join(directory(root, 'runtimes'), r.id + '.json');
|
||||
write(file, r, true);
|
||||
try { assertProcessing(root, admission.generation); }
|
||||
catch (e) { unlinkSync(file); throw e; }
|
||||
return r;
|
||||
}
|
||||
export function runtime(root, id) {
|
||||
if (!UUID.test(id)) fail();
|
||||
const r = validate(read(join(directory(root, 'runtimes'), id + '.json')));
|
||||
if (r.id !== id || r.root !== rootTag(root)) fail();
|
||||
return r;
|
||||
}
|
||||
export function updateRuntime(root, id, update) {
|
||||
if (Object.keys(update).some(k => !['pid', 'startTime', 'boot', 'group', 'runnerPid', 'runnerStartTime',
|
||||
'containerId', 'phase', 'haltGeneration', 'members'].includes(k))) fail();
|
||||
const r = validate({ ...runtime(root, id), ...update });
|
||||
return write(join(directory(root, 'runtimes'), id + '.json'), r);
|
||||
}
|
||||
export function unregisterRuntime(root, id) {
|
||||
runtime(root, id);
|
||||
unlinkSync(join(directory(root, 'runtimes'), id + '.json'));
|
||||
}
|
||||
export function listRuntimes(root) {
|
||||
return readdirSync(directory(root, 'runtimes')).filter(n => !n.startsWith('.receipt-')).map(name => {
|
||||
if (!name.endsWith('.json') || !UUID.test(name.slice(0, -5))) fail();
|
||||
return runtime(root, name.slice(0, -5));
|
||||
});
|
||||
}
|
||||
export function readStops(root, generation) {
|
||||
if (!UUID.test(generation)) fail();
|
||||
return readdirSync(directory(root, 'stops', generation)).filter(name => !name.startsWith('.receipt-')).map(name => read(join(directory(root, 'stops', generation), name)))
|
||||
.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
||||
}
|
||||
function stopRecord(root, generation, r, outcome, signals, problem = null, receipt = null) {
|
||||
const doc = { version: 1, id: randomUUID(), generation, runtime: r.id, kind: r.kind,
|
||||
business: r.business, run: r.run, ...(receipt ? { stop: receipt.id, reason: receipt.reason, owner: receipt.owner } : {}), at: new Date().toISOString(), outcome, signals, ...(problem ? { problem } : {}),
|
||||
identity: r.pid ? { pid: r.pid, startTime: r.startTime, boot: r.boot } : null };
|
||||
return write(join(directory(root, 'stops', generation), `${r.id}.${outcome}.json`), doc, true);
|
||||
}
|
||||
function groupOf(pid) {
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]);
|
||||
} catch { return null; }
|
||||
}
|
||||
function groupMembers(pgid) {
|
||||
return readdirSync('/proc').filter(n => /^\d+$/.test(n)).map(Number)
|
||||
.filter(pid => groupOf(pid) === pgid).map(processIdentity).filter(Boolean);
|
||||
}
|
||||
function signal(r, sig, members) {
|
||||
if (sig === 'SIGTERM' && r.kind === 'session' && r.runnerPid !== undefined) {
|
||||
const runner = { pid: r.runnerPid, startTime: r.runnerStartTime, boot: r.boot };
|
||||
if (alive(r) !== true || alive(runner) !== true || groupOf(runner.pid) !== r.pid) return false;
|
||||
try { process.kill(runner.pid, sig); return true; } catch { return false; }
|
||||
}
|
||||
// A surviving, start-time-checked member anchors the original group even after its leader exits.
|
||||
// Never signal a recycled group merely because its numeric id matches.
|
||||
const verified = r.group
|
||||
? members.some(m => alive(m) === true && groupOf(m.pid) === r.pid)
|
||||
: alive(r) === true;
|
||||
if (!verified) return false;
|
||||
try { process.kill(r.group ? -r.pid : r.pid, sig); return true; } catch { return false; }
|
||||
}
|
||||
export function createRuntimeSignaller(record, onMembers = () => {}) {
|
||||
let members = record.group && alive(record) === true && groupOf(record.pid) === record.pid
|
||||
? groupMembers(record.pid) : record.members ?? [];
|
||||
return sig => {
|
||||
if (record.group && members.some(m => alive(m) === true && groupOf(m.pid) === record.pid)) {
|
||||
const found = groupMembers(record.pid);
|
||||
members = [...members, ...found.filter(m => !members.some(old => old.pid === m.pid && old.startTime === m.startTime))];
|
||||
}
|
||||
try { if (record.group) onMembers(members.slice(0, 64)); } catch { return false; }
|
||||
return signal(record, sig, members);
|
||||
};
|
||||
}
|
||||
export function runtimeGroupState(record) {
|
||||
if (!record.group) return alive(record);
|
||||
if (alive(record) === true && groupOf(record.pid) === record.pid) return true;
|
||||
if ((record.members ?? []).some(m => alive(m) === true && groupOf(m.pid) === record.pid)) return true;
|
||||
// An unknown survivor may be ours or a recycled group. Neither permits a signal or quiescence.
|
||||
return groupMembers(record.pid).length ? null : false;
|
||||
}
|
||||
// One first-owner stop operation per canonical root/business/run. Never async-wrap the
|
||||
// exported function: callers joining an in-flight stop must receive the identical Promise.
|
||||
const stopping = new Map();
|
||||
const STOP_GRACE_MS = 5000, STOP_OBSERVE_MS = 2000;
|
||||
function stopKey(root, business, run) {
|
||||
const hex = createHash('sha256').update(JSON.stringify([rootTag(root), business, run])).digest('hex');
|
||||
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`;
|
||||
}
|
||||
function stopPaths(root, key) {
|
||||
const dir = directory(root, 'run-stops');
|
||||
return { claim: join(dir, key + '.claim.json'), result: join(dir, key + '.result.json') };
|
||||
}
|
||||
function optionalRead(file) {
|
||||
try { lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
||||
return read(file);
|
||||
}
|
||||
function stopResult(doc, expected) {
|
||||
const fields = ['version','id','root','business','run','reason','owner','at','runtime','kind','outcome','signals','identity'];
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
|
||||
Object.keys(doc).length !== fields.length || Object.keys(doc).some(k => !fields.includes(k)) ||
|
||||
doc.version !== 1 || doc.business !== expected.business || doc.run !== expected.run ||
|
||||
doc.root !== expected.root || !UUID.test(doc.id) || !['fire','kill-switch'].includes(doc.reason) ||
|
||||
typeof doc.at !== 'string' || !Number.isFinite(Date.parse(doc.at)) || new Date(doc.at).toISOString() !== doc.at ||
|
||||
(doc.runtime !== null && !UUID.test(doc.runtime)) ||
|
||||
(doc.kind !== null && !['session','task','container','discord','effect'].includes(doc.kind)) ||
|
||||
!['stopped','killed','alive','identity-changed','unknown'].includes(doc.outcome) ||
|
||||
!Array.isArray(doc.signals) || doc.signals.length > 2 ||
|
||||
doc.signals.some(s => !['SIGTERM','SIGKILL'].includes(s)) || new Set(doc.signals).size !== doc.signals.length ||
|
||||
(doc.signals.length === 2 && doc.signals.join(',') !== 'SIGTERM,SIGKILL') ||
|
||||
!doc.owner || typeof doc.owner !== 'object' || Array.isArray(doc.owner)) fail();
|
||||
if (doc.reason === 'fire' ? Object.keys(doc.owner).join(',') !== 'decision' || typeof doc.owner.decision !== 'string' || !ID.test(doc.owner.decision) :
|
||||
Object.keys(doc.owner).join(',') !== 'generation' || !UUID.test(doc.owner.generation)) fail();
|
||||
if (doc.identity !== null && (!doc.identity || Object.keys(doc.identity).sort().join(',') !== 'boot,pid,startTime' ||
|
||||
!Number.isSafeInteger(doc.identity.pid) || doc.identity.pid < 2 ||
|
||||
typeof doc.identity.startTime !== 'string' || !/^\d+$/.test(doc.identity.startTime) || !UUID.test(doc.identity.boot))) fail();
|
||||
return { run: doc.run, outcome: doc.outcome, pid: doc.identity?.pid ?? null,
|
||||
signals: doc.signals, receipt: doc };
|
||||
}
|
||||
export function stopRun(args, options = {}) {
|
||||
try {
|
||||
if (!args || Object.keys(args).sort().join(',') !== 'business,owner,reason,root,run' ||
|
||||
typeof args.root !== 'string' || typeof args.business !== 'string' || !ID.test(args.business) ||
|
||||
typeof args.run !== 'string' || !ID.test(args.run) ||
|
||||
!args.owner || typeof args.owner !== 'object' || Array.isArray(args.owner)) fail();
|
||||
if (args.reason === 'fire') {
|
||||
if (Object.keys(args.owner).join(',') !== 'decision' || typeof args.owner.decision !== 'string' || !ID.test(args.owner.decision)) fail();
|
||||
} else if (args.reason === 'kill-switch') {
|
||||
if (Object.keys(args.owner).join(',') !== 'generation' || !UUID.test(args.owner.generation)) fail();
|
||||
const state = readControl(args.root);
|
||||
if (!state.halted || state.generation !== args.owner.generation) throw new ProcessingError('processing-generation-changed');
|
||||
} else fail();
|
||||
const root = rootTag(args.root), key = stopKey(args.root, args.business, args.run);
|
||||
const local = root + ':' + key;
|
||||
if (stopping.has(local)) return stopping.get(local);
|
||||
const pending = Promise.resolve().then(() => ownStop({ ...args, rootHash: root }, key, options)).catch(e => {
|
||||
if (!e.code?.startsWith('processing-')) throw e;
|
||||
return { run: args.run, outcome: 'unknown', pid: null, signals: [], receipt: null };
|
||||
});
|
||||
stopping.set(local, pending);
|
||||
pending.then(() => stopping.delete(local), () => stopping.delete(local));
|
||||
return pending;
|
||||
} catch (e) { return Promise.reject(e); }
|
||||
}
|
||||
async function ownStop(args, key, options) {
|
||||
const { root, rootHash, business, run, reason, owner } = args;
|
||||
const files = stopPaths(root, key), expected = { root: rootHash, business, run };
|
||||
const old = optionalRead(files.result);
|
||||
if (old) return stopResult(old, expected);
|
||||
const now = options.now ?? Date.now, sleep = options.sleep ?? wait;
|
||||
const grace = options.graceMs ?? STOP_GRACE_MS, observe = options.killWaitMs ?? STOP_OBSERVE_MS;
|
||||
const started = now();
|
||||
const claim = { version: 1, id: randomUUID(), ...expected, reason, owner,
|
||||
supervisor: processIdentity(process.pid), at: new Date().toISOString(), deadline: Date.now() + grace + observe };
|
||||
const winner = write(files.claim, claim, true);
|
||||
if (winner.root !== rootHash || winner.business !== business || winner.run !== run || !UUID.test(winner.id) ||
|
||||
!Number.isFinite(winner.deadline) || winner.deadline > Date.now() + STOP_GRACE_MS + STOP_OBSERVE_MS + 1000) fail();
|
||||
if (winner.id !== claim.id) {
|
||||
// Another host owns delivery. No lease stealing or fresh deadlines on owner loss.
|
||||
while (Date.now() <= winner.deadline) {
|
||||
const result = optionalRead(files.result);
|
||||
if (result) return stopResult(result, expected);
|
||||
if (alive(winner.supervisor) !== true) break;
|
||||
await sleep(20);
|
||||
}
|
||||
return { run, outcome: 'unknown', pid: null, signals: [], receipt: null };
|
||||
}
|
||||
let record = null, signals = [], initialIdentity = null;
|
||||
const finish = outcome => {
|
||||
const doc = { version: 1, id: claim.id, ...expected, reason, owner, at: new Date().toISOString(),
|
||||
runtime: record?.id ?? null, kind: record?.kind ?? null, outcome, signals: [...signals],
|
||||
identity: initialIdentity };
|
||||
return stopResult(write(files.result, doc, true), expected);
|
||||
};
|
||||
const matches = listRuntimes(root).filter(r => r.business === business && r.run === run);
|
||||
if (matches.length !== 1) return finish('unknown');
|
||||
record = matches[0];
|
||||
if (reason === 'kill-switch') record = updateRuntime(root, record.id, { haltGeneration: owner.generation });
|
||||
if (record.pid) initialIdentity = { pid: record.pid, startTime: record.startTime, boot: record.boot };
|
||||
if (record.containerName) {
|
||||
let containerStop = options.containerStop;
|
||||
if (!containerStop) {
|
||||
const { createContainerStopper } = await import('./containers.mjs');
|
||||
containerStop = createContainerStopper({ dataRoot: root });
|
||||
}
|
||||
const result = await containerStop(record, { graceMs: grace, killWaitMs: observe });
|
||||
signals = result.signals;
|
||||
return finish(result.outcome === 'killed' ? 'killed' :
|
||||
['terminated','already-exited'].includes(result.outcome) ? 'stopped' : 'unknown');
|
||||
}
|
||||
const identity = options.identity ?? processIdentity;
|
||||
let members = record.group && alive(record) === true && groupOf(record.pid) === record.pid
|
||||
? groupMembers(record.pid) : record.members ?? [];
|
||||
const status = r => {
|
||||
if (!r.pid) return r.phase === 'ended' ? false : null;
|
||||
const current = identity(r.pid);
|
||||
if (current && (current.pid !== r.pid || current.startTime !== r.startTime || current.boot !== r.boot))
|
||||
return 'identity-changed';
|
||||
if (initialIdentity && (initialIdentity.pid !== r.pid || initialIdentity.startTime !== r.startTime || initialIdentity.boot !== r.boot))
|
||||
return 'identity-changed';
|
||||
if (options.state) {
|
||||
const observed = options.state(r);
|
||||
return observed === true && !current && !r.group ? null : observed;
|
||||
}
|
||||
return runtimeGroupState({ ...r, members });
|
||||
};
|
||||
const deliver = (r, sig) => {
|
||||
// Re-check immediately before the actual signal, including fixture delivery.
|
||||
if (status(r) !== true) return false;
|
||||
if (r.group) updateRuntime(root, r.id, { members: members.slice(0,64) });
|
||||
return options.signal ? options.signal(r, sig) : signal(r, sig, members);
|
||||
};
|
||||
const termEnd = started + grace, deadline = termEnd + observe;
|
||||
let termTried = false, killTried = false;
|
||||
while (now() <= deadline) {
|
||||
try { record = runtime(root, record.id); }
|
||||
catch {
|
||||
const present = readdirSync(directory(root, 'runtimes')).includes(record.id + '.json');
|
||||
return finish(present ? 'unknown' : signals.includes('SIGKILL') ? 'killed' : 'stopped');
|
||||
}
|
||||
if (!initialIdentity && record.pid) initialIdentity = { pid: record.pid, startTime: record.startTime, boot: record.boot };
|
||||
if (record.group && members.some(m => alive(m) === true && groupOf(m.pid) === record.pid)) {
|
||||
const found = groupMembers(record.pid);
|
||||
members = [...members, ...found.filter(m => !members.some(old => old.pid === m.pid && old.startTime === m.startTime))];
|
||||
}
|
||||
let live = status(record);
|
||||
if (live === 'identity-changed') return finish('identity-changed');
|
||||
if ((record.phase === 'ended' && !record.group) || live === false)
|
||||
return finish(signals.includes('SIGKILL') ? 'killed' : 'stopped');
|
||||
if (record.pid && live === true && !termTried) {
|
||||
termTried = true;
|
||||
if (deliver(record, 'SIGTERM')) signals.push('SIGTERM');
|
||||
}
|
||||
if (record.pid && live === true && now() >= termEnd && !killTried) {
|
||||
killTried = true;
|
||||
if (deliver(record, 'SIGKILL')) signals.push('SIGKILL');
|
||||
}
|
||||
if (now() >= deadline) {
|
||||
live = status(record);
|
||||
return finish(live === 'identity-changed' ? 'identity-changed' : live === true ? 'alive' :
|
||||
live === false ? signals.includes('SIGKILL') ? 'killed' : 'stopped' : 'unknown');
|
||||
}
|
||||
await sleep(Math.min(20, Math.max(1, deadline - now())));
|
||||
}
|
||||
return finish('unknown');
|
||||
}
|
||||
|
||||
// A later exit is new evidence, never a replacement for a fire caller's first result.
|
||||
function laterExit(root, first, receipt, generation) {
|
||||
try {
|
||||
const current = runtime(root, first.id);
|
||||
if (current.pid) {
|
||||
const identity = processIdentity(current.pid);
|
||||
if (identity && (identity.startTime !== current.startTime || identity.boot !== current.boot)) return false;
|
||||
if (runtimeGroupState(current) !== false) return false;
|
||||
} else if (current.phase !== 'ended') return false;
|
||||
const key = stopKey(root, first.business, first.run);
|
||||
write(join(directory(root, 'run-stops'), key + '.' + generation + '.observation.json'), {
|
||||
version: 1, id: randomUUID(), stop: receipt.id, generation, runtime: first.id,
|
||||
root: rootTag(root), business: first.business, run: first.run, outcome: 'exited', at: new Date().toISOString(),
|
||||
}, true);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function sweepRuntimes(root, generation, { graceMs = 5000, killWaitMs = 2000, containerStop = null,
|
||||
beforeStop = () => {}, stopOptions = {} } = {}) {
|
||||
const state = readControl(root);
|
||||
if (!state.halted || state.generation !== generation) throw new ProcessingError('processing-generation-changed');
|
||||
const prior = readStops(root, generation);
|
||||
const results = await Promise.all(listRuntimes(root).map(async first => {
|
||||
const complete = prior.find(s => s.runtime === first.id && s.outcome !== 'unknown');
|
||||
if (complete) return complete;
|
||||
let problem = null;
|
||||
try { await beforeStop(first, generation); } catch { problem = 'brake-unconfirmed'; }
|
||||
const result = await stopRun({ root, business: first.business, run: first.run,
|
||||
reason: 'kill-switch', owner: { generation } }, { graceMs, killWaitMs, containerStop, ...stopOptions });
|
||||
let outcome = result.outcome === 'stopped' ? 'terminated' : result.outcome === 'killed' ? 'killed' : 'unknown';
|
||||
if (outcome === 'unknown' && result.receipt && !problem && laterExit(root, first, result.receipt, generation))
|
||||
outcome = 'terminated';
|
||||
if (problem) outcome = 'unknown';
|
||||
return stopRecord(root, generation, first, outcome, result.signals, problem, result.receipt);
|
||||
|
||||
}));
|
||||
const remaining = results.filter(r => r.outcome === 'unknown').length;
|
||||
return { quiescent: remaining === 0, remaining, stopped: results.length - remaining, results };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Host-owned, per-data-root admission state. No model, git or credential imports.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync,
|
||||
renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export class ProcessingError extends Error {
|
||||
constructor(code) { super(code); this.name = 'ProcessingError'; this.code = code; }
|
||||
}
|
||||
const fail = code => { throw new ProcessingError(code); };
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@/-]{0,159}$/;
|
||||
const identity = value => typeof value === 'string' && ID.test(value);
|
||||
const FIELDS = ['version', 'generation', 'operation', 'halted', 'at', 'business', 'human', 'recovered'];
|
||||
const MAX_BYTES = 8192;
|
||||
|
||||
export function controlDirectory(dataRoot) {
|
||||
try {
|
||||
const root = realpathSync(dataRoot), st = lstatSync(root);
|
||||
if (!st.isDirectory() || st.uid !== process.getuid()) fail('processing-control-unsafe');
|
||||
return join(root, 'control');
|
||||
} catch { fail('processing-control-unsafe'); }
|
||||
}
|
||||
function parent(dataRoot) {
|
||||
const dir = controlDirectory(dataRoot);
|
||||
let st;
|
||||
try { st = lstatSync(dir); }
|
||||
catch (e) {
|
||||
if (e.code === 'ENOENT') fail('processing-uninitialized');
|
||||
fail('processing-control-unsafe');
|
||||
}
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o700)
|
||||
fail('processing-control-unsafe');
|
||||
return dir;
|
||||
}
|
||||
function valid(doc) {
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
|
||||
Object.keys(doc).length !== FIELDS.length || Object.keys(doc).some(k => !FIELDS.includes(k)) ||
|
||||
doc.version !== 1 || !UUID.test(doc.generation) || typeof doc.halted !== 'boolean' ||
|
||||
typeof doc.recovered !== 'boolean' || typeof doc.at !== 'string' ||
|
||||
!Number.isFinite(Date.parse(doc.at)) || new Date(doc.at).toISOString() !== doc.at)
|
||||
fail('processing-state-invalid');
|
||||
if (doc.operation === null) {
|
||||
if (doc.halted || doc.business !== null || doc.human !== null || doc.recovered) fail('processing-state-invalid');
|
||||
} else if (!UUID.test(doc.operation) || !identity(doc.business) || !identity(doc.human)) fail('processing-state-invalid');
|
||||
return Object.freeze(doc);
|
||||
}
|
||||
export function readControl(dataRoot) {
|
||||
const file = join(parent(dataRoot), 'processing.json');
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > MAX_BYTES)
|
||||
fail('processing-state-invalid');
|
||||
const buf = Buffer.alloc(MAX_BYTES + 1);
|
||||
let size = 0, n;
|
||||
while ((n = readSync(fd, buf, size, buf.length - size, null)) > 0) {
|
||||
size += n;
|
||||
if (size > MAX_BYTES) fail('processing-state-invalid');
|
||||
}
|
||||
return valid(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buf.subarray(0, size))));
|
||||
} catch { fail('processing-state-invalid'); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
function publish(dataRoot, doc) {
|
||||
const dir = parent(dataRoot), file = join(dir, 'processing.json'), temp = join(dir, `.state-${randomUUID()}`);
|
||||
let fd, dirfd;
|
||||
try {
|
||||
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeFileSync(fd, JSON.stringify(valid(doc)) + '\n');
|
||||
fsyncSync(fd); closeSync(fd); fd = undefined;
|
||||
renameSync(temp, file);
|
||||
dirfd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
fsyncSync(dirfd);
|
||||
} catch { fail('processing-state-invalid'); }
|
||||
finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
if (dirfd !== undefined) closeSync(dirfd);
|
||||
try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') fail('processing-state-invalid'); }
|
||||
}
|
||||
}
|
||||
|
||||
// First-use host bootstrap only. A present directory with no file is not a new installation.
|
||||
export function initializeControl(dataRoot) {
|
||||
const dir = controlDirectory(dataRoot);
|
||||
try { mkdirSync(dir, { mode: 0o700 }); }
|
||||
catch (e) {
|
||||
if (e.code === 'EEXIST') return readControl(dataRoot);
|
||||
fail('processing-control-unsafe');
|
||||
}
|
||||
const rootfd = openSync(realpathSync(dataRoot), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { fsyncSync(rootfd); } finally { closeSync(rootfd); }
|
||||
publish(dataRoot, { version: 1, generation: randomUUID(), operation: null, halted: false,
|
||||
at: new Date().toISOString(), business: null, human: null, recovered: false });
|
||||
return readControl(dataRoot);
|
||||
}
|
||||
export function assertProcessing(dataRoot, generation = null) {
|
||||
const state = readControl(dataRoot);
|
||||
if (state.halted) fail('processing-halted');
|
||||
if (generation !== null && state.generation !== generation) fail('processing-generation-changed');
|
||||
return state;
|
||||
}
|
||||
|
||||
// Called only by the coordinator after the broker has proved a human.
|
||||
export function transition(dataRoot, { halted, business, human, generation = null }) {
|
||||
if (typeof halted !== 'boolean' || !identity(business) || !identity(human)) fail('invalid-request');
|
||||
parent(dataRoot);
|
||||
let current, recovered = false;
|
||||
try { current = readControl(dataRoot); }
|
||||
catch (e) {
|
||||
if (!halted || e.code !== 'processing-state-invalid') throw e;
|
||||
recovered = true;
|
||||
}
|
||||
if (!halted && generation !== current.generation) fail('processing-generation-changed');
|
||||
if (current?.halted === halted) return { ...current, changed: false };
|
||||
const operation = randomUUID();
|
||||
const state = { version: 1, generation: operation, operation, halted,
|
||||
at: new Date().toISOString(), business, human, recovered };
|
||||
publish(dataRoot, state);
|
||||
return { ...state, changed: true };
|
||||
}
|
||||
export function processingStatus(dataRoot) {
|
||||
try { return readControl(dataRoot); }
|
||||
catch (e) {
|
||||
if (!(e instanceof ProcessingError)) throw e;
|
||||
return { halted: e.code === 'processing-uninitialized' ? null : true, invalid: true, code: e.code };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initializeControl, transition } from '../src/state.mjs';
|
||||
const api = await import('../src/containers.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
|
||||
function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'processing-container-')); initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
const actor = { business: 'demo', human: 'jason', halted: true };
|
||||
test('container admission publishes its generated name and root/runtime labels before dispatch', t => {
|
||||
assert.equal(typeof api.prepareContainer, 'function');
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'run-1' });
|
||||
const args = api.containerArgs(r, { args: ['request'] });
|
||||
assert.match(r.containerName, /^mosaic-[a-f0-9]{12}-[a-f0-9-]{36}$/);
|
||||
assert.ok(args.includes(r.containerName)); assert.ok(args.includes(`mosaic.root=${r.root}`));
|
||||
assert.ok(args.includes(`mosaic.runtime=${r.id}`)); assert.ok(args.includes(`MOSAIC_PROCESSING_GENERATION=${r.generation}`));
|
||||
assert.deepEqual(args.slice(-2), ['mosaic-agent', 'request']);
|
||||
transition(root, actor); assert.throws(() => api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'new' }), e => e.code === 'processing-halted');
|
||||
});
|
||||
function inspected(r, running = true, id = 'a'.repeat(64)) {
|
||||
return { Id: id, Name: '/' + r.containerName, Config: { Labels: { 'mosaic.root': r.root, 'mosaic.runtime': r.id } }, State: { Running: running } };
|
||||
}
|
||||
test('container sweep sends TERM then KILL only after verifying exact id and both labels', async t => {
|
||||
assert.equal(typeof api.createContainerStopper, 'function');
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
|
||||
const calls = []; let running = true;
|
||||
const stop = api.createContainerStopper({ docker: async args => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'inspect') return JSON.stringify(inspected(r, running));
|
||||
if (args.includes('KILL')) running = false;
|
||||
return '';
|
||||
} });
|
||||
const result = await stop(r, { graceMs: 30, killWaitMs: 100 });
|
||||
assert.equal(result.outcome, 'killed'); assert.deepEqual(result.signals, ['SIGTERM', 'SIGKILL']);
|
||||
assert.deepEqual(calls.filter(c => c[0] === 'kill'), [['kill', '--signal', 'TERM', 'a'.repeat(64)], ['kill', '--signal', 'KILL', 'a'.repeat(64)]]);
|
||||
});
|
||||
test('foreign labels and a replaced container id stay unknown and are never signalled', async t => {
|
||||
assert.equal(typeof api.createContainerStopper, 'function');
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
|
||||
const calls = [];
|
||||
const stop = api.createContainerStopper({ docker: async args => {
|
||||
calls.push(args); const doc = inspected(r); doc.Config.Labels['mosaic.root'] = 'b'.repeat(64); return JSON.stringify(doc);
|
||||
} });
|
||||
assert.equal((await stop(r, { graceMs: 1, killWaitMs: 1 })).outcome, 'unknown');
|
||||
assert.equal(calls.filter(c => c[0] === 'kill').length, 0);
|
||||
let reads = 0; calls.length = 0;
|
||||
const replacement = api.createContainerStopper({ docker: async args => {
|
||||
calls.push(args); if (args[0] !== 'inspect') return '';
|
||||
return JSON.stringify(inspected(r, true, (++reads > 1 ? 'b' : 'a').repeat(64)));
|
||||
} });
|
||||
assert.equal((await replacement(r, { graceMs: 1, killWaitMs: 1 })).outcome, 'unknown');
|
||||
assert.equal(calls.filter(c => c[0] === 'kill').length, 0);
|
||||
});
|
||||
test('an unverified missing container remains unknown rather than claiming a pending start ended', async t => {
|
||||
assert.equal(typeof api.createContainerStopper, 'function');
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'container', business: 'instance', run: 'one' });
|
||||
const stop = api.createContainerStopper({ docker: async () => { throw new Error('no container'); } });
|
||||
assert.equal((await stop(r, { graceMs: 5, killWaitMs: 5 })).outcome, 'unknown');
|
||||
});
|
||||
|
||||
test('a removed --rm container is settled only by its trusted owner completion receipt', async t => {
|
||||
const { updateRuntime } = await import('../src/runtimes.mjs');
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
|
||||
let removed = false;
|
||||
const stop = api.createContainerStopper({ dataRoot: root, docker: async args => {
|
||||
if (args[0] === 'kill') { removed = true; updateRuntime(root, r.id, { phase: 'ended' }); return ''; }
|
||||
if (removed) throw Error('removed by --rm');
|
||||
return JSON.stringify(inspected(r));
|
||||
} });
|
||||
const result = await stop(r, { graceMs: 30, killWaitMs: 100 });
|
||||
assert.equal(result.outcome, 'terminated'); assert.deepEqual(result.signals, ['SIGTERM']);
|
||||
});
|
||||
|
||||
test('after signalling a verified container, successful empty Docker listing proves --rm removed that id', async t => {
|
||||
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
|
||||
let removed = false;
|
||||
const stop = api.createContainerStopper({ docker: async args => {
|
||||
if (args[0] === 'kill') { removed = true; return ''; }
|
||||
if (args[0] === 'ps') return '';
|
||||
if (removed) throw Error('removed');
|
||||
return JSON.stringify(inspected(r));
|
||||
} });
|
||||
const result = await stop(r, { graceMs: 20, killWaitMs: 100 });
|
||||
assert.equal(result.outcome, 'terminated'); assert.deepEqual(result.signals, ['SIGTERM']);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readControl, assertProcessing } from '../src/state.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity, unregisterRuntime } from '../src/runtimes.mjs';
|
||||
const { createProcessing } = await import('../src/coordinator.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
|
||||
const actor = { business: 'demo', human: 'jason' };
|
||||
function fixture(t) {
|
||||
assert.equal(typeof createProcessing, 'function');
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-coordinator-'));
|
||||
const c = createProcessing({ dataRoot: root, graceMs: 80, killWaitMs: 1000 });
|
||||
t.after(async () => { await c.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, c };
|
||||
}
|
||||
|
||||
test('halt publishes before awaiting stops, resume refuses pending, and only clear permits new work', async t => {
|
||||
const { root, c } = fixture(t);
|
||||
const p = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const exit = once(p, 'exit');
|
||||
t.after(async () => { if (p.signalCode === null && p.exitCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await exit; } });
|
||||
await once(p.stdout, 'data');
|
||||
const r = registerRuntime(root, { business: 'demo', run: 'standin', kind: 'session' });
|
||||
updateRuntime(root, r.id, { ...processIdentity(p.pid), group: true, phase: 'running' });
|
||||
const halted = c.halt(actor);
|
||||
assert.equal(readControl(root).halted, true, 'durable gate is set before any stop await');
|
||||
await assert.rejects(c.resume({ ...actor, generation: readControl(root).generation }), { code: 'processing-stop-pending' });
|
||||
const result = await halted;
|
||||
assert.equal(result.quiescent, true);
|
||||
assert.deepEqual(await exit, [null, 'SIGKILL']);
|
||||
assert.throws(() => assertProcessing(root), { code: 'processing-halted' });
|
||||
const resumed = await c.resume({ ...actor, generation: result.generation });
|
||||
assert.equal(resumed.halted, false);
|
||||
assert.doesNotThrow(() => assertProcessing(root));
|
||||
});
|
||||
|
||||
test('a new coordinator replays a persisted halt and an unresolved start keeps clear refused', async t => {
|
||||
const { root, c } = fixture(t);
|
||||
const pending = registerRuntime(root, { business: 'demo', run: 'starting', kind: 'session' });
|
||||
const result = await c.halt(actor);
|
||||
assert.equal(result.quiescent, false);
|
||||
await c.close();
|
||||
const next = createProcessing({ dataRoot: root, graceMs: 10, killWaitMs: 10 });
|
||||
try {
|
||||
assert.equal(next.read().halted, true);
|
||||
await assert.rejects(next.resume({ ...actor, generation: result.generation }), { code: 'processing-stop-pending' });
|
||||
unregisterRuntime(root, pending.id);
|
||||
const resumed = await next.resume({ ...actor, generation: result.generation });
|
||||
assert.equal(resumed.halted, false);
|
||||
} finally { await next.close(); }
|
||||
});
|
||||
|
||||
test('a new halt generation never inherits a previous quiescent status before its sweep', async t => {
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const { transition } = await import('../src/state.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-counts-'));
|
||||
const processing = createProcessing({ dataRoot: root });
|
||||
t.after(async () => { await processing.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
transition(root, { business: 'demo', human: 'jason', halted: true });
|
||||
assert.equal(processing.read().quiescent, false);
|
||||
await processing.reconcile(); assert.equal(processing.read().quiescent, true);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initializeControl, transition } from '../src/state.mjs';
|
||||
const api = await import('../src/effects.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
|
||||
function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'processing-effect-')); initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
const actor = { business: 'demo', human: 'jason', halted: true };
|
||||
test('halt aborts an active local request and prevents a subsequent effect after halt/resume', { timeout: 4000 }, async t => {
|
||||
assert.equal(typeof api.withProcessing, 'function');
|
||||
const root = fixture(t); let started;
|
||||
const ready = new Promise(resolve => { started = resolve; });
|
||||
const work = api.withProcessing(root, async scope => {
|
||||
await new Promise(resolve => { scope.signal.addEventListener('abort', resolve, { once: true }); started(); });
|
||||
scope.guard();
|
||||
});
|
||||
await ready; const halted = transition(root, actor);
|
||||
transition(root, { ...actor, halted: false, generation: halted.generation });
|
||||
await assert.rejects(work, e => e.code === 'processing-generation-changed');
|
||||
});
|
||||
test('processing scopes isolate roots and cancel only the registered child group', async t => {
|
||||
assert.equal(typeof api.withProcessing, 'function');
|
||||
const a = fixture(t), b = fixture(t);
|
||||
let admitted;
|
||||
const ready = new Promise(resolve => { admitted = resolve; });
|
||||
const task = api.withProcessing(a, async scope => {
|
||||
const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>process.exit(0));console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const exit = once(child, 'exit'); t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
|
||||
scope.track(child); await once(child.stdout, 'data'); admitted();
|
||||
const outcome = await exit; assert.deepEqual(outcome, [0, null]); scope.guard();
|
||||
});
|
||||
await ready; transition(b, actor);
|
||||
await api.withProcessing(a, async scope => scope.guard());
|
||||
transition(a, actor);
|
||||
await assert.rejects(task, e => e.code === 'processing-halted');
|
||||
});
|
||||
test('halted scopes never invoke their body and unscoped human work has no processing context', async t => {
|
||||
assert.equal(typeof api.withProcessing, 'function');
|
||||
const root = fixture(t); transition(root, actor); let called = false;
|
||||
await assert.rejects(api.withProcessing(root, async () => { called = true; }), e => e.code === 'processing-halted');
|
||||
assert.equal(called, false); assert.equal(api.currentProcessing(), undefined);
|
||||
});
|
||||
|
||||
test('host git is stopped promptly by the active scope rather than waiting for its long deadline', async t => {
|
||||
const { mkdirSync, writeFileSync } = await import('node:fs');
|
||||
const { runGit, gitEnv } = await import('../../seat/src/checkout.mjs');
|
||||
const root = fixture(t), bin = join(root, 'bin'); mkdirSync(bin);
|
||||
writeFileSync(join(bin, 'git'), `#!${process.execPath}\nprocess.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000);\n`, { mode: 0o755 });
|
||||
const begin = Date.now();
|
||||
const task = api.withProcessing(root, async () => runGit(['status'], { ...gitEnv(), PATH: bin + ':' + process.env.PATH }, { timeoutMs: 10000, graceMs: 100 }));
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); transition(root, actor);
|
||||
await assert.rejects(task, e => e.code === 'processing-halted');
|
||||
assert.ok(Date.now() - begin < 2000, 'the halt cancels the actual git process');
|
||||
});
|
||||
|
||||
test('a child leader exiting does not erase an unverified surviving group receipt', async t => {
|
||||
const { listRuntimes } = await import('../src/runtimes.mjs');
|
||||
const root = fixture(t);
|
||||
let child;
|
||||
await api.withProcessing(root, async scope => {
|
||||
const script = `const {spawn}=require('node:child_process');spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});console.log('ready');setTimeout(()=>process.exit(0),80);`;
|
||||
child = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
|
||||
const closed = once(child, 'close');
|
||||
scope.track(child); await once(child.stdout, 'data'); await closed;
|
||||
});
|
||||
const records = listRuntimes(root);
|
||||
assert.equal(records.length, 1, 'unknown group remains discoverable to the independent halt sweep');
|
||||
assert.equal(records[0].pid, child.pid);
|
||||
assert.equal(records[0].group, true);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initializeControl, transition } from '../src/state.mjs';
|
||||
import { sandboxProblem } from '../../seat/src/sandbox.mjs';
|
||||
const skip = sandboxProblem() ?? false;
|
||||
test('the actual nested read-only control mount refuses unlink, replacement and parent removal', { skip }, t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-mount-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
initializeControl(root); transition(root, { business: 'demo', human: 'jason', halted: true });
|
||||
const file = join(root, 'control', 'processing.json'), bytes = readFileSync(file), inode = statSync(file).ino;
|
||||
writeFileSync(join(root, 'replacement'), 'off replacement');
|
||||
const r = spawnSync('bwrap', ['--unshare-user', '--unshare-pid', '--die-with-parent',
|
||||
'--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib',
|
||||
'--symlink', 'usr/lib64', '/lib64', '--proc', '/proc', '--dev', '/dev',
|
||||
'--bind', root, '/fixture', '--ro-bind', join(root, 'control'), '/fixture/control',
|
||||
'/bin/sh', '-c', 'rm /fixture/control/processing.json && exit 10; echo unlink-refused; mv /fixture/replacement /fixture/control/processing.json && exit 11; echo replace-refused; rm -rf /fixture/control && exit 12; echo parent-refused'],
|
||||
{ encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /unlink-refused/); assert.match(r.stdout, /replace-refused/); assert.match(r.stdout, /parent-refused/);
|
||||
assert.deepEqual(readFileSync(file), bytes); assert.equal(statSync(file).ino, inode);
|
||||
});
|
||||
test('the image entrypoint refuses halted and stale generations using the shared reader', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-entry-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const initial = initializeControl(root), entry = new URL('../src/entrypoint.mjs', import.meta.url).pathname;
|
||||
const run = generation => spawnSync(process.execPath, [entry, root], { env: { ...process.env, MOSAIC_PROCESSING_GENERATION: generation }, encoding: 'utf8' });
|
||||
assert.equal(run(initial.generation).status, 0);
|
||||
const halt = transition(root, { business: 'demo', human: 'jason', halted: true });
|
||||
assert.equal(run(initial.generation).status, 3);
|
||||
transition(root, { business: 'demo', human: 'jason', halted: false, generation: halt.generation });
|
||||
const stale = run(initial.generation); assert.equal(stale.status, 3); assert.match(stale.stderr, /processing-generation-changed/);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { initializeControl, transition } from '../src/state.mjs';
|
||||
const api = await import('../src/runtimes.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
|
||||
const actor = { business: 'demo', human: 'jason', halted: true };
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-runtime-'));
|
||||
initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
async function child(t, stubborn = false) {
|
||||
const p = spawn(process.execPath, ['-e', `process.on('SIGTERM',()=>{${stubborn ? '' : 'process.exit(0)'}});console.log('ready');setInterval(()=>{},1000)`], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const ended = once(p, 'exit');
|
||||
t.after(async () => { if (p.exitCode === null && p.signalCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await ended; } });
|
||||
await once(p.stdout, 'data');
|
||||
return { p, ended };
|
||||
}
|
||||
function receipt(root, p) {
|
||||
const r = api.registerRuntime(root, { kind: 'session', business: 'demo', run: 'run' });
|
||||
return api.updateRuntime(root, r.id, { ...api.processIdentity(p.pid), phase: 'running', group: true });
|
||||
}
|
||||
|
||||
// Without TERM, a cooperative child would survive until unnecessary escalation.
|
||||
test('halt terminates a tracked stand-in and leaves honest write-once stop evidence', async t => {
|
||||
assert.equal(typeof api.registerRuntime, 'function');
|
||||
const root = fixture(t), { p, ended } = await child(t);
|
||||
const r = receipt(root, p);
|
||||
const evidence = join(root, 'evidence.json'); writeFileSync(evidence, 'immutable existing result');
|
||||
const state = transition(root, actor);
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 150, killWaitMs: 1000 });
|
||||
assert.equal(result.quiescent, true);
|
||||
assert.equal(result.remaining, 0);
|
||||
assert.deepEqual(await ended, [0, null]);
|
||||
const stops = api.readStops(root, state.generation);
|
||||
assert.equal(stops.length, 1);
|
||||
assert.equal(stops[0].runtime, r.id);
|
||||
assert.equal(stops[0].outcome, 'terminated');
|
||||
assert.deepEqual(stops[0].signals, ['SIGTERM']);
|
||||
assert.equal(readFileSync(evidence, 'utf8'), 'immutable existing result');
|
||||
const bytes = JSON.stringify(stops);
|
||||
await api.sweepRuntimes(root, state.generation, { graceMs: 10, killWaitMs: 1000 });
|
||||
assert.equal(JSON.stringify(api.readStops(root, state.generation)), bytes);
|
||||
});
|
||||
|
||||
// Dropping the KILL escalation leaves this actual child alive and fails quiescence.
|
||||
test('a TERM-resistant workload is killed after the bounded wait', async t => {
|
||||
assert.equal(typeof api.registerRuntime, 'function');
|
||||
const root = fixture(t), { p, ended } = await child(t, true);
|
||||
receipt(root, p);
|
||||
const state = transition(root, actor), begin = Date.now();
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 80, killWaitMs: 1000 });
|
||||
assert.equal(result.quiescent, true);
|
||||
assert.deepEqual(await ended, [null, 'SIGKILL']);
|
||||
assert.ok(Date.now() - begin >= 80);
|
||||
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM', 'SIGKILL']);
|
||||
});
|
||||
|
||||
// An unchecked stale start identity would terminate a different process.
|
||||
test('wrong start ticks or boot identity cannot signal a live replacement', async t => {
|
||||
assert.equal(typeof api.registerRuntime, 'function');
|
||||
const root = fixture(t), { p } = await child(t);
|
||||
const r = receipt(root, p);
|
||||
api.updateRuntime(root, r.id, { startTime: '0' });
|
||||
const state = transition(root, actor);
|
||||
await api.sweepRuntimes(root, state.generation, { graceMs: 30, killWaitMs: 30 });
|
||||
await delay(30);
|
||||
assert.equal(p.exitCode, null); assert.equal(p.signalCode, null);
|
||||
assert.equal(api.readStops(root, state.generation)[0].outcome, 'unknown');
|
||||
});
|
||||
|
||||
test('a pending start cannot be reported quiescent and a second root is never signalled', async t => {
|
||||
assert.equal(typeof api.registerRuntime, 'function');
|
||||
const a = fixture(t), b = fixture(t), { p } = await child(t);
|
||||
api.registerRuntime(a, { kind: 'session', business: 'demo', run: 'pending' });
|
||||
receipt(b, p);
|
||||
const state = transition(a, actor);
|
||||
const result = await api.sweepRuntimes(a, state.generation, { graceMs: 30, killWaitMs: 30 });
|
||||
assert.equal(result.quiescent, false);
|
||||
assert.equal(result.remaining, 1);
|
||||
assert.equal(p.exitCode, null); assert.equal(p.signalCode, null);
|
||||
});
|
||||
|
||||
// A group leader can exit on TERM while its child ignores it. Quiescence is the whole group.
|
||||
test('escalation still kills the tracked group after its leader exits', async t => {
|
||||
const root = fixture(t);
|
||||
const script = `const {spawn}=require('node:child_process');
|
||||
const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});
|
||||
c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000);`;
|
||||
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
|
||||
const [line] = await once(p.stdout, 'data');
|
||||
const descendant = Number(String(line).trim());
|
||||
const r = receipt(root, p), state = transition(root, actor);
|
||||
const outcome = await api.sweepRuntimes(root, state.generation, { graceMs: 80, killWaitMs: 1000 });
|
||||
assert.equal(outcome.quiescent, true);
|
||||
assert.equal(api.processIdentity(descendant), null, 'stubborn descendant must not survive the group leader');
|
||||
assert.equal(api.readStops(root, state.generation).find(s => s.runtime === r.id).outcome, 'killed');
|
||||
});
|
||||
|
||||
// Session TERM goes to the runner, leaving the outer namespace for forced escalation.
|
||||
test('session TERM targets its verified runner before forcing the outer namespace', async t => {
|
||||
const root = fixture(t);
|
||||
const script = `const {spawn}=require('node:child_process');
|
||||
process.on('SIGTERM',()=>require('node:fs').writeFileSync(${JSON.stringify(join(root, 'outer-term'))},'unexpected'));
|
||||
const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>process.exit(0));console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});
|
||||
c.stdout.once('data',()=>console.log(c.pid));c.once('exit',()=>process.exit(0));setInterval(()=>{},1000);`;
|
||||
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
|
||||
const [line] = await once(p.stdout, 'data'), runner = api.processIdentity(Number(String(line).trim()));
|
||||
const r = receipt(root, p);
|
||||
api.updateRuntime(root, r.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
|
||||
const state = transition(root, actor);
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 300, killWaitMs: 1000 });
|
||||
assert.equal(result.quiescent, true);
|
||||
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM']);
|
||||
assert.equal(api.runtime(root, r.id).haltGeneration, state.generation);
|
||||
assert.equal(existsSync(join(root, 'outer-term')), false);
|
||||
});
|
||||
|
||||
// Publishing a receipt is not durable if its newly-created containing directory can vanish.
|
||||
test('new runtime and stop directories sync their containing directories', t => {
|
||||
const root = fixture(t);
|
||||
const script = `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';
|
||||
const real=fs.fsyncSync,seen=[];fs.fsyncSync=fd=>{seen.push(fs.readlinkSync('/proc/self/fd/'+fd));return real(fd)};
|
||||
syncBuiltinESMExports();const api=await import(process.argv[1]);
|
||||
const {transition}=await import(process.argv[2]);
|
||||
const r=api.registerRuntime(process.argv[3],{kind:'task',business:'demo',run:'gone'});
|
||||
api.updateRuntime(process.argv[3],r.id,{phase:'ended'});
|
||||
const s=transition(process.argv[3],{business:'demo',human:'jason',halted:true});
|
||||
await api.sweepRuntimes(process.argv[3],s.generation);
|
||||
console.log(JSON.stringify({seen,generation:s.generation}));`;
|
||||
const child = spawnSync(process.execPath, ['--input-type=module', '-e', script,
|
||||
new URL('../src/runtimes.mjs', import.meta.url).href, new URL('../src/state.mjs', import.meta.url).href, root], { encoding: 'utf8' });
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
const { seen, generation } = JSON.parse(child.stdout);
|
||||
assert.ok(seen.filter(p => p === join(root, 'control')).length >= 3, 'runtime mkdir, halt rename and stops mkdir sync control');
|
||||
assert.ok(seen.includes(join(root, 'control', 'stops')), 'generation directory creation syncs stops');
|
||||
assert.ok(seen.includes(join(root, 'control', 'stops', generation)), 'stop receipt syncs generation directory');
|
||||
});
|
||||
|
||||
test('a vanished group leader with unverified surviving members stays unknown, never quiescent', async t => {
|
||||
const root = fixture(t);
|
||||
const script = `const {spawn}=require('node:child_process');const c=spawn(process.execPath,['-e',"console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000)`;
|
||||
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
|
||||
const [line] = await once(p.stdout, 'data'); const descendant = Number(String(line).trim());
|
||||
receipt(root, p); const exited = once(p, 'exit'); p.kill('SIGTERM'); await exited;
|
||||
const state = transition(root, actor);
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 20, killWaitMs: 30 });
|
||||
assert.equal(result.quiescent, false); assert.equal(result.remaining, 1);
|
||||
assert.notEqual(api.processIdentity(descendant), null, 'never signal a group with no verified surviving anchor');
|
||||
});
|
||||
test('persisted member identity anchors a group after the leader exited before the sweep', async t => {
|
||||
const root = fixture(t);
|
||||
const script = `const {spawn}=require('node:child_process');const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000)`;
|
||||
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
|
||||
const [line] = await once(p.stdout, 'data'), descendant = Number(String(line).trim()), r = receipt(root, p);
|
||||
api.updateRuntime(root, r.id, { members: [api.processIdentity(descendant)] });
|
||||
const exited = once(p, 'exit'); p.kill('SIGTERM'); await exited;
|
||||
const state = transition(root, actor);
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 30, killWaitMs: 1000 });
|
||||
assert.equal(result.quiescent, true); assert.equal(api.processIdentity(descendant), null);
|
||||
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM', 'SIGKILL']);
|
||||
});
|
||||
|
||||
test('an unconfirmed secondary brake does not leave a verified workload running or claim success', async t => {
|
||||
const root = fixture(t), { p, ended } = await child(t);
|
||||
receipt(root, p); const state = transition(root, actor);
|
||||
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 60, killWaitMs: 1000,
|
||||
beforeStop: () => { throw Error('fixture journal unavailable'); } });
|
||||
await ended;
|
||||
assert.equal(api.processIdentity(p.pid), null);
|
||||
assert.equal(result.quiescent, false); assert.equal(result.remaining, 1);
|
||||
const stops = api.readStops(root, state.generation); assert.equal(stops[0].outcome, 'unknown');
|
||||
assert.equal(stops[0].problem, 'brake-unconfirmed');
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const api = await import('../src/state.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
|
||||
throw e;
|
||||
});
|
||||
const root = t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'processing-state-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
};
|
||||
const actor = { business: 'demo', human: 'jason' };
|
||||
const stateFile = dir => join(dir, 'control', 'processing.json');
|
||||
const refusal = code => e => e.code === code;
|
||||
|
||||
// Removing durable publication must let a new reader launch after halt.
|
||||
test('halt persists across readers and resume needs the current generation', t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const dir = root(t);
|
||||
const initial = api.initializeControl(dir);
|
||||
assert.equal(initial.halted, false);
|
||||
const halted = api.transition(dir, { ...actor, halted: true });
|
||||
assert.equal(halted.halted, true);
|
||||
assert.notEqual(halted.generation, initial.generation);
|
||||
assert.equal(api.readControl(dir).generation, halted.generation);
|
||||
assert.throws(() => api.assertProcessing(dir), refusal('processing-halted'));
|
||||
assert.throws(() => api.transition(dir, { ...actor, halted: false, generation: initial.generation }), refusal('processing-generation-changed'));
|
||||
const resumed = api.transition(dir, { ...actor, halted: false, generation: halted.generation });
|
||||
assert.equal(resumed.halted, false);
|
||||
assert.doesNotThrow(() => api.assertProcessing(dir));
|
||||
// A start that waited across halt+resume must not use its old admission.
|
||||
assert.throws(() => api.assertProcessing(dir, initial.generation), refusal('processing-generation-changed'));
|
||||
assert.equal(lstatSync(stateFile(dir)).mode & 0o777, 0o600);
|
||||
assert.equal(lstatSync(join(dir, 'control')).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
// Reinitializing a missing file as off would bypass a previously published halt.
|
||||
test('first initialization is explicit; missing state after control exists fails closed and halt repairs it', t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const dir = root(t);
|
||||
assert.throws(() => api.readControl(dir), refusal('processing-uninitialized'));
|
||||
const initial = api.initializeControl(dir);
|
||||
assert.equal(api.initializeControl(dir).generation, initial.generation);
|
||||
unlinkSync(stateFile(dir));
|
||||
assert.throws(() => api.initializeControl(dir), refusal('processing-state-invalid'));
|
||||
assert.throws(() => api.assertProcessing(dir), refusal('processing-state-invalid'));
|
||||
assert.throws(() => api.transition(dir, { ...actor, halted: false, generation: initial.generation }), refusal('processing-state-invalid'));
|
||||
const halted = api.transition(dir, { ...actor, halted: true });
|
||||
assert.equal(halted.recovered, true);
|
||||
assert.equal(halted.halted, true);
|
||||
assert.equal(api.transition(dir, { ...actor, halted: false, generation: halted.generation }).halted, false);
|
||||
});
|
||||
|
||||
// A read error, permissive mode or unbounded payload must never look like off.
|
||||
for (const [name, edit] of [
|
||||
['invalid JSON', file => writeFileSync(file, '{')],
|
||||
['unknown keys', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), extra: true }))],
|
||||
['bad timestamp', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), at: 'yesterday' }))],
|
||||
['nonboolean state', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), halted: 0 }))],
|
||||
['bad generation', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), generation: 'bad' }))],
|
||||
['loose mode', file => chmodSync(file, 0o644)],
|
||||
['oversized file', file => writeFileSync(file, ' '.repeat(8193))],
|
||||
]) test(`refuses ${name} instead of enabling work`, t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const dir = root(t); api.initializeControl(dir); edit(stateFile(dir));
|
||||
assert.throws(() => api.assertProcessing(dir), refusal('processing-state-invalid'));
|
||||
});
|
||||
|
||||
test('a symlink leaf never follows its target; human halt replaces only the leaf', t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const dir = root(t); api.initializeControl(dir);
|
||||
const outside = join(dir, 'outside'), target = readFileSync(stateFile(dir));
|
||||
writeFileSync(outside, target, { mode: 0o600 });
|
||||
unlinkSync(stateFile(dir)); symlinkSync(outside, stateFile(dir));
|
||||
assert.throws(() => api.readControl(dir), refusal('processing-state-invalid'));
|
||||
api.transition(dir, { ...actor, halted: true });
|
||||
assert.deepEqual(readFileSync(outside), target);
|
||||
assert.equal(lstatSync(stateFile(dir)).isSymbolicLink(), false);
|
||||
});
|
||||
|
||||
test('unsafe parent and interrupted initialization never get repaired as off', t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const dir = root(t); mkdirSync(join(dir, 'control'), { mode: 0o700 });
|
||||
assert.throws(() => api.initializeControl(dir), refusal('processing-state-invalid'));
|
||||
chmodSync(join(dir, 'control'), 0o777);
|
||||
assert.throws(() => api.transition(dir, { ...actor, halted: true }), refusal('processing-control-unsafe'));
|
||||
});
|
||||
|
||||
test('repeated halt is a no-op and a different root keeps running', t => {
|
||||
assert.equal(typeof api.initializeControl, 'function');
|
||||
const a = root(t), b = root(t); api.initializeControl(a); api.initializeControl(b);
|
||||
const first = api.transition(a, { ...actor, halted: true });
|
||||
const before = readFileSync(stateFile(a));
|
||||
assert.equal(api.transition(a, { ...actor, halted: true }).generation, first.generation);
|
||||
assert.deepEqual(readFileSync(stateFile(a)), before);
|
||||
assert.doesNotThrow(() => api.assertProcessing(b));
|
||||
});
|
||||
|
||||
// Coercing undefined/numbers through a regex would publish unverifiable actor metadata.
|
||||
test('transition rejects nonstring identity before publishing state', t => {
|
||||
const dir = root(t); api.initializeControl(dir);
|
||||
const before = readFileSync(stateFile(dir));
|
||||
for (const bad of [undefined, null, 123, ['jason']]) {
|
||||
assert.throws(() => api.transition(dir, { ...actor, human: bad, halted: true }), refusal('invalid-request'));
|
||||
assert.deepEqual(readFileSync(stateFile(dir)), before);
|
||||
}
|
||||
});
|
||||
|
||||
// A lost control directory after power failure could permit a later off bootstrap.
|
||||
test('bootstrap syncs the containing root as well as the state and control directory', t => {
|
||||
const dir = root(t);
|
||||
const script = `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module';
|
||||
const real=fs.fsyncSync, seen=[];
|
||||
fs.fsyncSync=fd=>{seen.push(fs.readlinkSync('/proc/self/fd/'+fd));return real(fd)};
|
||||
syncBuiltinESMExports();
|
||||
const {initializeControl}=await import(process.argv[1]);initializeControl(process.argv[2]);
|
||||
console.log(JSON.stringify(seen));`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script,
|
||||
new URL('../src/state.mjs', import.meta.url).href, dir], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const seen = JSON.parse(result.stdout);
|
||||
assert.ok(seen.includes(dir), 'control mkdir must be durable in the root');
|
||||
assert.ok(seen.includes(join(dir, 'control')), 'state rename must be durable');
|
||||
assert.ok(seen.some(p => p.startsWith(join(dir, 'control', '.state-'))), 'state bytes must be durable');
|
||||
});
|
||||
|
||||
// Each fstat/read guard is exercised independently, rather than hidden by a later parse refusal.
|
||||
test('state ownership and bounded read guards refuse before trusting a document', t => {
|
||||
const dir = root(t); api.initializeControl(dir); const file = stateFile(dir);
|
||||
const script = `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';
|
||||
const stat=fs.fstatSync,read=fs.readSync;let reads=0;
|
||||
const file=process.argv[2],which=process.argv[3],valid=fs.readFileSync(file);
|
||||
if(which==='oversize')fs.writeFileSync(file,Buffer.concat([valid,Buffer.alloc(8193,32)]));
|
||||
fs.fstatSync=fd=>{const s=stat(fd);if(fs.readlinkSync('/proc/self/fd/'+fd)===file){
|
||||
if(which==='owner')s.uid++;
|
||||
if(which==='grow')fs.writeFileSync(file,Buffer.concat([valid,Buffer.alloc(8193-valid.length,32)]));
|
||||
}return s};
|
||||
fs.readSync=(...args)=>{if(fs.readlinkSync('/proc/self/fd/'+args[0])===file)reads++;return read(...args)};syncBuiltinESMExports();
|
||||
const {readControl}=await import(process.argv[1]);let code=null;
|
||||
try{readControl(process.argv[4])}catch(e){code=e.code}console.log(JSON.stringify({code,reads}));`;
|
||||
for (const which of ['owner', 'oversize', 'grow']) {
|
||||
const valid = readFileSync(file);
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script,
|
||||
new URL('../src/state.mjs', import.meta.url).href, file, which, dir], { encoding: 'utf8' });
|
||||
writeFileSync(file, valid);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const answer = JSON.parse(result.stdout); assert.equal(answer.code, 'processing-state-invalid', which);
|
||||
if (which !== 'grow') assert.equal(answer.reads, 0, `${which}: refuse before reading bytes`);
|
||||
else assert.ok(answer.reads > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('canonical root aliases address one switch and never create independent control state', t => {
|
||||
const a = root(t), b = root(t), alias = join(b, 'alias');
|
||||
api.initializeControl(a); symlinkSync(a, alias, 'dir');
|
||||
assert.equal(api.initializeControl(alias).generation, api.readControl(a).generation);
|
||||
const halted = api.transition(alias, { ...actor, halted: true });
|
||||
assert.equal(api.readControl(a).generation, halted.generation);
|
||||
assert.equal(api.readControl(a).halted, true);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user