Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82df17347d | ||
|
|
47c2cb33c3 | ||
|
|
762a6711ff | ||
|
|
de928da2dc | ||
|
|
781d91624a | ||
|
|
d16ccdb910 | ||
|
|
60f5ca8fa8 | ||
|
|
3c30f1036a | ||
|
|
2ad8c4f5b0 | ||
|
|
29cb93aa6a | ||
|
|
0622680756 | ||
|
|
8ddd8ada58 | ||
|
|
eecb77b549 | ||
|
|
d46f0aa6de | ||
|
|
56af37187e | ||
|
|
f953b1d383 | ||
|
|
b149ba3e04 | ||
|
|
a3a378204d | ||
|
|
8de42b55b0 | ||
|
|
4641555b0a | ||
|
|
9619734322 | ||
|
|
e95c917a14 | ||
|
|
f39f518d0d |
@@ -25,15 +25,10 @@ green at every step. Not production software — a proven foundation.
|
||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||
repository root; everything else gets a dedicated directory (`roles/`,
|
||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||
2. **Configuration**: each stack instance has exactly one system config. The
|
||||
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
|
||||
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
|
||||
that names it. `~` is the home of the OS user that runs the instance. A
|
||||
process reads only the config `MOSAIC_CONFIG` names, or the default's when
|
||||
that is unset. Configs are created only by
|
||||
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
|
||||
any problem. Repo-scoped role authority lives in `roles/*.json`
|
||||
(versioned, reviewed commits only).
|
||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||
fail-closed on any problem. Repo-scoped role authority lives in
|
||||
`roles/*.json` (versioned, reviewed commits only).
|
||||
3. **Secrets** never enter the repository or container images; auth is
|
||||
runtime-only (read-only mount or environment variable).
|
||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||
@@ -182,8 +177,8 @@ Full reference — usage, fields, exit codes, safety notes:
|
||||
|
||||
## Data map (canon)
|
||||
|
||||
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
|
||||
per named instance: system config (user-authored; never auto-written).
|
||||
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
||||
auto-written).
|
||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
# Row 194 build notes
|
||||
|
||||
Design r4 approved: DESIGN.md sha256 824ef884 at 7d0788236 (Webby webby-0014, review at e1e64ff01 on
|
||||
webby/reviews; Mos mos-1042; Sage sage-1387 "build now"). Build base: refactor aa1bd209e merged into
|
||||
honker/row194 at fa9eaa4c3.
|
||||
|
||||
## Build notes from the approval (each gets a test and a mutant)
|
||||
|
||||
- W-N1 (webby-0014 N1): launch refuses `instance-firing` while I has a `fire.stopped` with no `fire.done`.
|
||||
Test case in T9c. Mutant M59: the check is dropped.
|
||||
- W-N2 (webby-0014 N2): the broker's hire-time check requires roleVersion 2, so a definition with no
|
||||
action lists refuses. Test case in T8b. Mutant M60: the version check is dropped.
|
||||
- Grace (sage-1387): 5 s, one constant shared with row 191.
|
||||
|
||||
## Chain state (2026-10-20T19:45Z)
|
||||
|
||||
Rows 190, 191 and 193 have no product commits yet (rocko/row191 and darkwing/row190 sit at da230ca48, the
|
||||
brief commit). Slices that don't need row 191 build first. S6 waits for row 191's candidate.
|
||||
|
||||
## Slices
|
||||
|
||||
| Slice | Scope | Tests | State |
|
||||
|---|---|---|---|
|
||||
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
|
||||
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
|
||||
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
|
||||
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | done, see S4 notes |
|
||||
| S5 | webui: Agents section and routes | T16 | done, see S5 notes. The "still running after fire" flag waits for S6 |
|
||||
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
|
||||
|
||||
## Changed assertions (D3 item 4, N4)
|
||||
|
||||
Listed here as the build changes them.
|
||||
|
||||
- `packages/business/tests/instance-settings.test.mjs:53, :67, :72`: the row 165 read and add views
|
||||
gain `kept: false` (D1, the read gains `kept` per instance). No other field changed.
|
||||
- `packages/business/tests/instance-settings.test.mjs:70` (`added.restart` true to false) and `:91`
|
||||
(`redefined.restart` true to false): the writer no longer asks for a restart. The bus decides,
|
||||
after its reload (D3 items 2-3). The two test names say so.
|
||||
- `packages/cli/tests/instances.test.mjs`: `setup` boots the bus from the file through `bootConfig`, as
|
||||
the unit does, instead of a one-role stub. The add test (T1) now expects the "no restart" line and
|
||||
binds `sec-1` on the running bus. The `RESTART_NEEDED` text pins moved, unchanged, to a new test that
|
||||
boots the old stub (`setup(t, { stub: true })`). Its coder has no credential references and the
|
||||
file's has, so the bus doesn't reload, prints `RESTART_NEEDED` and doesn't bind `sec-1` (D3 item 3).
|
||||
- `packages/webui/tests/instances.test.mjs:25`: the fake writer's default answer is `restart: false`,
|
||||
the usual answer now. The real-broker test boots through `bootConfig` and expects `restart: false`
|
||||
(`:71`) and the log line `done restart=false` (`:74`). The answer-shape rows at `:140-145` keep
|
||||
`restart: true`. Each row tests another field, or `restart: 'yes'`, so the value is not what they
|
||||
check.
|
||||
- `packages/bus/tests/instances-verb.test.mjs` T12 (S2c): the human delete now answers `{request}`
|
||||
where S2b refused `invalid-request`. The stub writer answers `instances.check` with the revision,
|
||||
and the calls list gains that one check, actor `jason`. Nothing else in the test changed.
|
||||
- `packages/bus/tests/tester-writes.test.mjs:19` (row 175 pin): TESTER_VERBS gains `instance.launch`,
|
||||
`instance.end` and `instance.request` (D4.1). TESTER_ORGANIZATION_VERBS is unchanged.
|
||||
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
|
||||
its boot copy is a stub without the file's credential references. A comment there says so.
|
||||
`reload.test.mjs` covers the reload from a file boot.
|
||||
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
|
||||
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
|
||||
checks are unchanged.
|
||||
- S2e (PRD 1.7 REQ-COS-3) takes back the S2d entry above. `role.hire` and `role.fire` left the hard
|
||||
waits, so `packages/bus/tests/hard-waits.test.mjs` and `packages/business/tests/hard-waits.test.mjs`
|
||||
drop them from WAITS, and the bus file's every-hard-wait loop is back to its S2c form. The business
|
||||
file gains a test that both are routed and not in GATED_ONLY.
|
||||
- `packages/bus/tests/reload.test.mjs` (S2e): its hard-wait example used `role.hire`, now routed. It
|
||||
uses `spend`. The check is unchanged.
|
||||
- `packages/business/tests/chief-of-staff.test.mjs` (S2e): the chief's pinned authority and the test
|
||||
name gain `role.hire` and `role.fire`. `packages/business/tests/role.test.mjs` gains the check that
|
||||
no other shipped role holds either.
|
||||
- `packages/harness/tests/tools.test.mjs` and `packages/harness/tests/bundle.test.mjs` (S2e): the
|
||||
chief's pinned tool lists gain `hire` and `fire`. Every held action needs a typed tool
|
||||
(`untypedActions`), so the tools come forward from S4.
|
||||
- `packages/bus/tests/chief-of-staff.test.mjs` (S2e): the S2d decision-form tests are rewritten to the
|
||||
reason form (below). The S2d raise tests with `arguments` are gone with the key.
|
||||
- `packages/bus/tests/lifecycle-verbs.test.mjs` V5 retry (S3a): `lifecycleDelete` on a retry now
|
||||
answers `{transaction, retry: true}` with the failed delete's transaction, where S2c answered
|
||||
`{transaction: null, retry: true}`. The launcher needs it to name `.retired/I-<transaction>`. The
|
||||
test checks the value and that the retry still neither checks nor writes the file.
|
||||
- `packages/harness/tests/tools.test.mjs:48` (S4): the launch tool's `instance` was an enum of the
|
||||
start-time listing. It is now `{type: "string", description, pattern: ID_PATTERN.source}`, and the
|
||||
listing moves to the tool description, which the test matches. `:107` (the pm test's chief check)
|
||||
asserts there is no enum and the description names `chief.launch.instances`.
|
||||
- `packages/business/tests/chief-of-staff.test.mjs` (S4): the contract test gains a match on the new
|
||||
sentence that a hire is listed for `launch` at once but runs only after the human sets its route.
|
||||
|
||||
- `packages/webui/tests/browser.test.mjs:21-22` (S5): the page's script list pins the scripts in
|
||||
order. It now has `/agents.js` between `/chat.js` and `/bus.js`, and the comment names row 194.
|
||||
|
||||
## S1 notes
|
||||
|
||||
- Tests: `tests/lifecycle-settings.test.mjs` (new) holds T6's business part (V5 order, last listed,
|
||||
clean delete, retired refusals for add and hire), T7 (V6) and the writer's hire and fire (D2.2).
|
||||
T8c is in `role.test.mjs`, T17 in `business.test.mjs`.
|
||||
- `deleteRefusal`'s entry half (`entry.tracker`, `entry.credentials`) follows V5's wording "holds
|
||||
credentials or a tracker bot". In a file that validates it is the same as the definition check: a
|
||||
definition with no credential refuses an entry tracker or credentials at load. So no test reaches it
|
||||
alone, and no mutant is listed for it.
|
||||
- END_PROTECTED is `assistant` only. The chief's own instance refuses delete and fire as
|
||||
`organization-instance-referenced` (launch.by), not as protected.
|
||||
- The writer refuses an `assistant` hire `organization-definition-refused` through `definitionFor`, as
|
||||
row 165's add does.
|
||||
- PRD draft: 69544f342 (sha256 c1f52d98…), sage-1395: adding to the fixed list is a PRD change.
|
||||
|
||||
## S2a notes (D3 live reload)
|
||||
|
||||
- `Broker.reloadBusiness(id, doc)` runs the boot check (`checkBusiness`, moved out of the
|
||||
constructor unchanged) on a clone, then refuses a changed `human` and swaps the document in.
|
||||
- The runtime reloads after every committed write, through the chain `bootConfig` uses
|
||||
(`loadBusiness`, `resolveInstance` with `organization.system`, `busBusiness`). Then `limits.refresh`
|
||||
runs. `instances.set` answers `restart: !live`. Any other verb name now throws `invalid-request`
|
||||
where it used to fall through to `plugins.set`.
|
||||
- D3 item 3 check: a role in the boot copy must keep its credential references and tracker bot. A role
|
||||
not in the boot copy must have neither. The tracker sync block must match. Otherwise no reload and
|
||||
`restart: true`. The first version compared one fingerprint over all roles, so every create failed
|
||||
it. T14's create test caught that.
|
||||
- Consumers checked against item 3 (`runtime.mjs` startBroker):
|
||||
- The Credentials store takes the role and sync references and the project roots it protects at
|
||||
start. The tasks adapter takes the bots at start. All of these are compared.
|
||||
- Limits read `settings.current` at each ask. The human proof reads `human` from the boot copy,
|
||||
and `reloadBusiness` refuses a changed human, so the two agree.
|
||||
- Web grants: the broker reads `roles.<I>.web` from its own document (`broker.mjs:1545`), so a
|
||||
reload carries the grant. A hireable definition has no `web` tool.
|
||||
- Usage loads its `usage-source` plug-ins from `b.plugins` at start. That was already so before
|
||||
this row (`plugins.set` never restarted them). A reload doesn't change it, and it isn't compared.
|
||||
- Instance-running: `instances.set` op change with a `definition` on a declared instance with a
|
||||
claim holder (or thread holders for a thread instance) refuses `instance-running` before the
|
||||
writer. A vars or launch change still passes.
|
||||
- Tests: `packages/bus/tests/reload.test.mjs` (T14, 6 tests).
|
||||
|
||||
## S2b notes (veto.set, the D4 hook, keep checks)
|
||||
|
||||
- `LIFECYCLE_HUMAN_VERBS` (exported from `broker.mjs`) has one entry per verb and per `instances.set`
|
||||
op: `instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`, `instances.set add`,
|
||||
`instances.set change`, `instances.set delete`. Choice: `#mayHuman(s, verb, target)` refuses
|
||||
`human-required` for an entry missing from the list. So the list and the calls can't drift apart,
|
||||
and T12 shows the hook got the exact entry: each listed human call passes. The tradeoff is that a
|
||||
new human verb has to join the list before it works, which is what row 193 needs anyway.
|
||||
- `bindHuman` already refuses any human but the owner, so the hook's owner check can't fail on any
|
||||
path today. It is the body row 193 replaces.
|
||||
- M46 (`veto.set` skips `#human`) is equivalent while the generic check runs first: no session
|
||||
without `s.human` gets past `human-required`, and only `bindHuman` sets `s.human`, always with
|
||||
`via: 'cli'` and `outsideAgent: true`. T18 shows the refusal. `#human` stays as the design asks.
|
||||
- `instances.set` now checks `op` in the broker (`add`, `change`, `delete`, else `invalid-request`).
|
||||
The tester's delete refuses `tester-refused` (B6). Until S2c records the delete request, a human
|
||||
delete refuses `invalid-request` after the hook. Without that, the writer's `set` op `delete` would
|
||||
commit with no running or starting check and no workspace rename.
|
||||
- Keep: the `role.revoke` reason branch refuses `instance-kept` for any caller, not only the chief.
|
||||
Only the chief holds `role.end` today, so this is the same set. A caller with `role.end` added
|
||||
later is held to the forbid too. The `organization.set` check refuses when any key of
|
||||
`args.instances` is kept, before the writer and before the grant is spent. The human's `limits.set`
|
||||
isn't checked: the human owns the forbid.
|
||||
- T13 needed no code. `organization.set` and `limits.set` take only `ORGANIZATION_KEYS`, and
|
||||
`limits` and `models` write only `launch.limits` and `launch.models`.
|
||||
- Tests: `chief-of-staff.test.mjs` T10 (reason end, decision end, model write), T13 and T18's
|
||||
veto.set and instances.set paths on a stub writer. T18's file-bytes check and its hire and fire
|
||||
cases come with S2d. `instances-verb.test.mjs` T12 (sessions, tester, hook). `reload.test.mjs`
|
||||
gains veto.set.
|
||||
|
||||
## S2c notes (lifecycle requests, terminate, delete)
|
||||
|
||||
- A request is an `action.allowed` event, action `lifecycle.launch`, `.regenerate`, `.delete` or
|
||||
`.end`, body `{action, request, via, by, ...}`. The human's also gets `human.input {kind: 'admin'}`.
|
||||
The tester's gets none. Steps are `lifecycle.taken`, `lifecycle.done {request, run?, transaction?,
|
||||
refusal?}` and `lifecycle.cancelled {request, by}`, with no role or run in the session. No new
|
||||
table or event kind, so no migration.
|
||||
- Host ops for the launcher pass (S3), each in LAUNCH_OPS: `lifecycleRequests`, `lifecycleTake`,
|
||||
`lifecycleDelete`, `lifecycleDone`. Choice: `lifecycleDelete` commits through the bus's own
|
||||
organization callback, so the D3 reload runs after it as after any other write. The tradeoff is
|
||||
one more host op instead of the launcher calling the writer.
|
||||
- The take re-checks: request-expired (60 s, `LIFECYCLE_REQUEST_MS`), then launch-revoked and
|
||||
instance-retired for launch and regenerate only, then tester-off for a tester request. A refused
|
||||
take writes `lifecycle.done` with the refusal, so the request closes. `lifecycle.end` never
|
||||
expires: the runs it names still need the signal.
|
||||
- instance-starting counts any taken request of I, and any open one still under 60 s, delete
|
||||
included. An open request past 60 s can only close request-expired, so it doesn't count. The end
|
||||
request doesn't count.
|
||||
- Delete order at the verb (V5): keys and identifier, the 64-hex revision, the retired-name retry
|
||||
gate, then `instances.check` (the writer's plan and validation, no write). A named organization
|
||||
refusal fails at once. A BusinessError (the last-listed refusal) is held until instance-running
|
||||
and instance-starting have passed, then answers invalid-request. Then revision-conflict, then the
|
||||
request with `{revision}`. `lifecycleDelete` checks running and starting again after the take
|
||||
(other requests only), then calls `instances.set` op delete with actor = the request's `by`.
|
||||
- Retry: a retired name that isn't in roles deletes again only when its last delete closed
|
||||
`workspace-rename-failed`. The request carries `retry: true`, and `lifecycleDelete` answers
|
||||
`{transaction: null, retry: true}` with no check and no write. The launcher (S3) retries the rename.
|
||||
- `lifecycleDone` carries `transaction`, so the delete's done names the commit the rename used.
|
||||
- Terminate (V3): every claim holder of I, or every thread holder for a thread instance, is revoked
|
||||
with the reason (default "ended by the human" or "ended by the tester"). The answer names the held
|
||||
runs plus the bound runs of I not yet ended, cancels open requests of I and lists taken ones. With
|
||||
none of these it refuses not-held. The tester ends only its own threads and cancels only its own
|
||||
requests.
|
||||
- The tester's business scope needs no check: its cap is bound to one business (M19), so another
|
||||
business's instance answers unknown-role. `instance.request` needs no tester-file check: a tester
|
||||
reads only requests it made.
|
||||
- The tester switch is read through a `testerOn` option. The runtime reads `bus.tester` from the
|
||||
live file through `settings.current`, so a change applies with no restart. Anything but `true`,
|
||||
or a read error, is off.
|
||||
- Refactor: the organization error mapping moved into `#organizationFail(e, verb)`, shared by the
|
||||
organization verbs and `lifecycleDelete`. Same mapping as before.
|
||||
- Deferred: the halted checks (S6, row 191) and W-N1 (S2d with fire).
|
||||
- Tests: `packages/bus/tests/lifecycle-verbs.test.mjs` (12 tests: T12 lifecycle part, T12a, V2-V5,
|
||||
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
|
||||
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
|
||||
not-held all died. They are not the M-list run.
|
||||
|
||||
## S2d notes (the chief's hire and fire, W-N1)
|
||||
|
||||
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
|
||||
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
|
||||
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
|
||||
`arguments.name`. The raise event records it, and the human's decision view shows it under
|
||||
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
|
||||
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
|
||||
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
|
||||
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
|
||||
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
|
||||
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
|
||||
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
|
||||
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
|
||||
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
|
||||
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
|
||||
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
|
||||
definition-not-hireable before the writer. Then the writer's hire, then the consume with
|
||||
`{transaction}`. Without `roleFile` a hire refuses not-configured.
|
||||
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
|
||||
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
|
||||
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
|
||||
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
|
||||
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
|
||||
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
|
||||
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
|
||||
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
|
||||
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
|
||||
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
|
||||
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
|
||||
the first one.
|
||||
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
|
||||
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
|
||||
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
|
||||
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
|
||||
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
|
||||
- `lifecycleView` shows `kill: true` when the request carries it.
|
||||
- Deferred: the kill escalation in the launcher (S3, S6).
|
||||
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
|
||||
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
|
||||
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
|
||||
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
|
||||
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
|
||||
died. They are not the M-list run.
|
||||
|
||||
## S2e notes (PRD 1.7 REQ-COS-3 routing, sage-1471, mos-1089)
|
||||
|
||||
- Role-policy change, for Webby to review on its own: `roles/chief-of-staff.json` withinRole gains
|
||||
`role.hire` and `role.fire`. `packages/business/src/vocabulary.mjs` and `packages/bus/src/broker.mjs`
|
||||
move both from the hard waits to ROUTED. `roles/chief-of-staff.md` and `roles/pm.md` drop them from
|
||||
the hard-wait list, which `workflow-roles.test.mjs:113` pins to GATED_ONLY. No other shipped role
|
||||
gains either action, and `role.test.mjs` pins that.
|
||||
- The call takes the reason form, as `role.end` does: `instance.hire {name, definition, vars?, reason}`
|
||||
and `instance.fire {name, reason}`, with no decision. The reason is 1-500 characters for both.
|
||||
- Choice: the decision form is gone, not kept beside the reason form. A within-role raise closes at
|
||||
once, so a decision there adds an event and no check. The `decision.raise` `arguments` key from S2d
|
||||
goes with it. Tradeoff: if a later PRD gates hire again, the raise path comes back as new code.
|
||||
- Choice: hire takes a reason too. The PRD logs every hire and fire with its reason.
|
||||
- Order: tester-refused, read-only and agent-required, keys, `role-not-chief` (launch.by),
|
||||
not-configured, `#checkAuthority` with `{target: name}`, instance-kept. `role-not-chief` comes before
|
||||
the authority check, so a non-chief role refuses `role-not-chief` even when its file grants the
|
||||
actions. A chief whose file lacks the grant refuses `decision-required`.
|
||||
- The writer gets only its own keys. The reason goes in the change context `{instance, run, reason}`,
|
||||
which changes.jsonl records. The hire's consume detail is `{reason, transaction}`.
|
||||
- W-N1 `#firing` now goes by order, since the reason form has no decision id: a `fire.stopped` of the
|
||||
subject with no later `fire.done` (`seq` after it) for the same subject. `fire.stopped` carries
|
||||
`{action, request}` and `fire.done` `{action, transaction}`.
|
||||
- Harness: the typed `hire` and `fire` tools (verbs `instance.hire` and `instance.fire`) come forward
|
||||
from S4, because a held action with no typed tool fails `untypedActions`. Each tool follows its own
|
||||
grant. `roles/chief-of-staff.md` already names them. The rest of the S4 contract text and the runner
|
||||
end to end for hire and fire stay in S4.
|
||||
- Going live is a later re-pin step with Mos (sage-1471). Landing the change doesn't put it on the live
|
||||
stack.
|
||||
- Correction to S2d (found in S2e): S2d's broker imported `business/src/role.mjs`, which is not in
|
||||
`seat/src/sandbox.mjs` RUNNER_FILES. The runner imports broker.mjs, so a session died with
|
||||
ERR_MODULE_NOT_FOUND: cli `launcher.test.mjs` failed 15 of 29 and seat `sandbox.test.mjs` 4 of 10 at
|
||||
089b92ca3. A bisect showed S2c passes 29 of 29. I had not run those two files for S2d. Fix:
|
||||
`unhireableAction` moves into vocabulary.mjs, which is mounted, and role.mjs re-exports it.
|
||||
RUNNER_FILES is unchanged, so the sandbox boundary does not widen. Rule from here (sage-1497): a
|
||||
change to broker.mjs imports, or those of any RUNNER_FILES file, runs these four singly: seat
|
||||
`sandbox.test.mjs`, cli `launcher.test.mjs`, cli `thread-launch.test.mjs` and cli
|
||||
`git-socket.test.mjs`.
|
||||
- Mutant (sage-1497): broker.mjs imports `unhireableAction` from role.mjs again. Killed: sandbox.test
|
||||
fails 4 of 10 (role.mjs outside RUNNER_FILES), launcher.test 15 of 29, thread-launch 7 of 11. git-socket
|
||||
passes 24 of 24 under it, so it does not catch this one and stays on the list for its own sandbox runs.
|
||||
- Tests: `packages/bus/tests/chief-of-staff.test.mjs` S2d block rewritten to the reason form (39 pass),
|
||||
with new tests for the reason bounds, `role-not-chief` with the grant, `decision-required` for a chief
|
||||
without each grant, W-N1 by order, and the runtime end to end with the reason in the receipt context.
|
||||
`packages/harness/tests/tools.test.mjs` gains the hire and fire tool shape test, and that no coder,
|
||||
reviewer or pm gets either tool.
|
||||
- Probe (not the M-list run): 16 mutants, all killed. ROUTED drops of each action in the broker and in
|
||||
vocabulary, the role file drops of each, `#firing` without the order and without the subject, the
|
||||
reason unchecked, the writer given the reason, the context without it, no `#checkAuthority`, no
|
||||
`role-not-chief`, the consume without the reason, the hire tool's reason optional, and the fire tool
|
||||
gated on the hire grant. The last one survived at first and a one-grant check in tools.test killed it.
|
||||
|
||||
## S3a notes (the launcher's lifecycle pass)
|
||||
|
||||
- `packages/cli/src/launcher.mjs` `lifecyclePass` runs on the tick after the thread and land passes.
|
||||
It lists the open requests (host op `lifecycleRequests`) and carries each under `serialize`: the
|
||||
take, the act, then `lifecycleDone`. A take that answers no closes nothing more here. A refusal
|
||||
goes in the launch log as `refused` with the request id. A done that throws leaves the request
|
||||
taken with no done, which S3b's CLI reports as `outcome-unknown`. The log line names a run that
|
||||
did start.
|
||||
- Launch: `check()` and `start()` gain `human`. The human's request may name any declared instance,
|
||||
as V2 asks. The listing limits the chief. launch.by still takes the `pm` branch (N8). A tester
|
||||
request launches with `launchedBy: "tester"`.
|
||||
- End: SIGTERM to each run the verb named and to any other run of the instance in the registry,
|
||||
each after the start-time check, with a `lifecycle-end` entry. The exit handler writes the one
|
||||
`end` record. A thread instance's end signals only the runs the verb named, so the tester's end
|
||||
leaves the human's threads alone. No SIGKILL escalation here: that is row 191's stop (S6).
|
||||
- Regenerate: the end's signals, then a wait until the old run leaves the registry, then a launch.
|
||||
The wait runs under `serialize`, so no other start runs meanwhile. Past the request's mark
|
||||
(`at` + 60 s, `lifecycleRequestMs` for tests) it closes `regenerate-timeout` and starts nothing. A
|
||||
closing host closes it `host-closing`. Tradeoff: one stuck regenerate holds other starts for up to
|
||||
60 s. The other choice, a second pass that comes back for it, needed state across ticks for the
|
||||
same result.
|
||||
- Delete (V5 steps 1-3): `instance-running` against the registry first, then `lifecycleDelete` (the
|
||||
writer's commit), then the rename of `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<tx>/`,
|
||||
both 0700. The file goes first, so a failed rename can't leave a launchable name on the old
|
||||
workspace. A failed rename closes `workspace-rename-failed` with the transaction. A later delete of
|
||||
the name is the retry, and the broker now answers the failed transaction, so the rename target is
|
||||
the same (changed assertion above). No workspace means nothing to rename.
|
||||
- `--no-launch` (row 163): the ticker now runs on a no-launch host, but only the lifecycle pass, and
|
||||
`act` closes each request `launches-disabled` before any signal, start or rename. The thread and
|
||||
land passes stay off, so `launcher.test.mjs`'s no-launch tests pass unchanged.
|
||||
- Tests: new `packages/cli/tests/lifecycle.test.mjs`, 9 tests on the in-process host of
|
||||
thread-launch.test.mjs, with ticks called by the test: T2 (unlisted and launch.by launches, a
|
||||
request past 60 s, a check refusal with its code in the log), T3 (`--no-launch`, `capacity-full`
|
||||
with the limit in the log, `instance-running`), T4 (end at the verb, the signal, one end record,
|
||||
the cancelled request never runs), T5 (a new run on the same workspace, `regenerate-timeout`
|
||||
with a SIGSTOPped runner, `regenerate-thread` at the verb), V5 (a failed rename then the retry,
|
||||
modes 0700, no workspace).
|
||||
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
|
||||
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
|
||||
imports did not change, and the four must-run files ran anyway.
|
||||
|
||||
## S3b notes (the CLI verbs)
|
||||
|
||||
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
|
||||
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
|
||||
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
|
||||
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
|
||||
add and set refuse `--wait` and `--reason`.
|
||||
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
|
||||
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
|
||||
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
|
||||
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
|
||||
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
|
||||
past cancelling.
|
||||
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
|
||||
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
|
||||
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
|
||||
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
|
||||
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
|
||||
and S5 can show it without a second verb. An answer with no instances map passes through as
|
||||
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
|
||||
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
|
||||
`the chief may not hire:` lines.
|
||||
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
|
||||
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
|
||||
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
|
||||
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
|
||||
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
|
||||
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
|
||||
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
|
||||
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
|
||||
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
|
||||
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
|
||||
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
|
||||
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
|
||||
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
|
||||
with two businesses hires and fires only in its own.
|
||||
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
|
||||
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
|
||||
file that no longer lists the name.
|
||||
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
|
||||
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
|
||||
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
|
||||
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
|
||||
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
|
||||
import. The four must-run files ran while the `instances` guard was being written, and the guard
|
||||
touches only the read. No test pins a hash of scripts/mosaic.
|
||||
|
||||
## S4 notes (the launch tool takes a hire, D3 item 5)
|
||||
|
||||
- `packages/harness/src/tools.mjs`: the launch tool's `instance` is a string with the ID_PATTERN
|
||||
pattern, not an enum fixed when the bundle was built. The start-time listing stays in the
|
||||
description. The host checks the listing at each launch (`instance-not-listed`), so the chief
|
||||
launches its own hire in the same session. The hire and fire tools came forward in S2e.
|
||||
- The runner does not enforce a tool's schema pattern. A bad name reaches the host and refuses
|
||||
`instance-not-listed`, so the end-to-end test asserts that. M15 (the enum kept) is killed by the
|
||||
tools.test shape assertion, not by the end-to-end test.
|
||||
- `packages/cli/tests/lifecycle.test.mjs` gains the T8 end to end: the chief's session hires
|
||||
`sec-2` (definition chief-information-security-officer, the shipped definition `hireable` accepts:
|
||||
message.send and record.write, no credential) with `TOOL hire`, a launch refuses `no-route`, the
|
||||
human sets the route with `instances.set` op change, the same session launches it (launchedBy
|
||||
chief), `NOT A NAME` and `reviewer` refuse `instance-not-listed`, then `TOOL fire` ends its run,
|
||||
leaves it `launched: false` and a later launch refuses `instance-not-listed`.
|
||||
- Route gap, raised with Sage (honker-0027): harness, model and thinking are agent-layer vars
|
||||
(`business/src/vars.mjs:142-147`) and hire vars allow only model and limits.* (N3). So a hire runs
|
||||
only after the human sets harness and thinking. D3 item 5 says the same session launches it, and
|
||||
it does, after that human step. Letting hire vars carry the route would widen the chief's
|
||||
authority, a policy change that is not mine to make. As built the human step stays, and
|
||||
`roles/chief-of-staff.md` says so. Sage ruled (b) in sage-1521: the human route step stays, and
|
||||
the manifest notes say that launch refuses `no-route` until the human sets the route.
|
||||
- sage-1521 also keeps exit 3 for every lifecycle outcome but done (outcome-unknown included, the
|
||||
S3b tradeoff line above stands for Webby) and the S3a choices (a `--no-launch` host runs only the
|
||||
lifecycle pass, `lifecycleDelete` answers the transaction on a retry). `roles/chief-of-staff.md`
|
||||
is role policy: the manifest names it and the review request flags it as a policy change.
|
||||
- `roles/chief-of-staff.md` (role policy, named in the manifest): one paragraph on the hire's route
|
||||
step and on a fired instance staying in the file off the list.
|
||||
- Singles, each run alone: business chief-of-staff 10/10, spec-review-contracts 3/3, role 19/19,
|
||||
resolve 10/10, harness bundle 22/22, tools 23/23, runner 34/34, pi-session 8/8, seat sandbox
|
||||
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, lifecycle 15/15, webui reads
|
||||
11/11. No test pins a hash of tools.mjs or roles/chief-of-staff.md. tools.mjs gains an import from
|
||||
vocabulary.mjs, already in RUNNER_FILES, and the four must-run files passed after it.
|
||||
|
||||
## Rebase onto row 192 (2026-10-20T22:10Z)
|
||||
|
||||
Row 192 (4429a104f, Q175 phase 1 SSO) changed webui `serve.mjs`, `cli.mjs`, `app.js` and
|
||||
`index.html`, the files S5 changes. Before S5 the branch was rebased onto origin/refactor
|
||||
f41729178 as `honker/row194-b2`. `honker/row194` stays at f70b4da50 (no force-push). The only file
|
||||
both sides changed was `PRD-1.7-DRAFT.md`, whose blob was the same on both (9bfc6f76), so its five
|
||||
draft commits dropped as already upstream. The tree check: the rebased tip differs from f70b4da50
|
||||
by exactly the base's changes. Every test file the branch changes, the four must-run files and
|
||||
webui sso, service and reads were run again singly on the new base: 23 files, all pass.
|
||||
|
||||
## S5 notes (the Console's Agents section, D7)
|
||||
|
||||
- `packages/webui/src/public/agents.js` (new, `window.MosaicAgents.section`): Settings draws it after
|
||||
the role instances, from the same instances read. The table has instance, definition, launched, kept
|
||||
and running runs, and each row has Launch, a reason box and Terminate. A human also gets Regenerate,
|
||||
Keep or Unkeep, and Delete. The protected assistant has no Delete. Each definition has a No-hire or
|
||||
Allow-hiring button, and a Create form adds an instance of an accepted definition. Retired names show
|
||||
as one line. D7's "retired" column is that line, since a retired name has no instance row.
|
||||
- Regenerate and Delete take a second click. The confirm says what happens: for Delete, "Its evidence
|
||||
and its workspace under .retired stay." Cancel asks nothing.
|
||||
- Choice: every answer that names a request is followed at `/api/agent/request` every 2 s, up to 90
|
||||
reads, until the request closes. D7 names launch and regenerate. Delete and terminate also answer a
|
||||
request (S2c), so the page follows them too, and the receipt shows the close or its refusal reason.
|
||||
A failed read is shown and the poll goes on. The request is never sent again. Past 90 reads the page
|
||||
says the request is still open and asks for a fresh read. A sign-out stops the poll.
|
||||
- With `send` false the section shows "Agent actions are unavailable here. Use mosaic business instance
|
||||
from the instance CLI." and every control is disabled. The tester sees Launch and Terminate only.
|
||||
- `serve.mjs`: `/api/instances` takes op `delete` with the revision and the instance alone and answers
|
||||
the request. `/api/agent/launch`, `/regenerate`, `/terminate` and `/api/veto` each have
|
||||
`instanceSend`'s boundary and one call to `agentWrite`, with only the route's keys. A reason is never
|
||||
logged. `GET /api/agent/request?id=` takes one request id and adds the reason for a closed refusal or
|
||||
a cancel. Every refusal code the S2/S3 verbs answer has a reason line (`AGENT_REASONS`), and a code
|
||||
missing there shows as itself. No route has a `--send` exception.
|
||||
- `cli.mjs`: `agentWrite` reaches only `instance.launch`, `instance.regenerate`, `instance.end`,
|
||||
`veto.set` and `instance.request` (`agentCaller` throws for any other). The tester listener gets
|
||||
launch, end and request only (D4.1).
|
||||
- D4.1 at the webui: the tester listener lets launch and terminate through and refuses regenerate and
|
||||
veto with no call. A tester delete on `/api/instances` is refused in `instanceSend` with no call
|
||||
(`tester-refused`). The bus refuses it too (broker tester-refused), and this keeps the webui from
|
||||
asking. The agents test covers it, and with the guard removed that test fails (control run).
|
||||
- Deferred to S6: the "still running after fire" flag needs the `lifecycle.end.alive` event, which
|
||||
comes with row 191's stop.
|
||||
- Tests: webui `agents.test.mjs` (new, 8: actions and logs, boundary refusals, read-only, refusal and
|
||||
unknown outcomes, the request read, delete, tester routes, agents.js served) and
|
||||
`agents-browser.test.mjs` (new, 1: read-only, launch followed to close, confirms, terminate reason,
|
||||
refusal, keep and unknown outcome).
|
||||
- Singles, each run alone, after the last code change: webui agents 8/8, agents-browser 1/1, browser
|
||||
1/1, instances 5/5, instances-browser 1/1, serve 4/4, service 31/31, sso 28/28, sso-browser 2/2,
|
||||
routes 5/5, routes-browser 1/1, tester-listener 10/10, tester-browser 2/2, tester-cli 2/2,
|
||||
settings-write 4/4, send 13/13, send-browser 2/2, shell 5/5, cli launcher 29/29, thread-launch
|
||||
11/11, git-socket 24/24, stack-copy 12/12, cli stack-release 174/174, seat sandbox 10/10. No test
|
||||
pins a hash of a file S5 changes. shell.test reads bus.js strings and its POST count, which S5 leaves
|
||||
alone because agents.js holds the section's writes.
|
||||
@@ -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,342 @@
|
||||
# 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 7 is on r5 at 762a6711f, not r6
|
||||
(Jason, Q191, through Mos mos-1149 and Sage sage-1623): the assistant and the chief of staff are separate
|
||||
roles, and only the chief raises a grant request. It also takes Mos's rulings on r6 items 1, 3 and 4
|
||||
(mos-1152): the heading below reads "For Jason at approval:" again with Mos's reading of the Q188 revokers,
|
||||
the machine-grant citation names r1 to r4, and the revoke note no longer says their Q-S5 is answered.
|
||||
|
||||
Only the chief of staff raises a grant request. The assistant, the user's main agent, never raises one.
|
||||
It passes the user's request to the chief and can revoke a grant in its own organization.
|
||||
|
||||
For Jason at approval:
|
||||
|
||||
- Q188's 'you, Mos or the tester' is read as the user, the assistant for grants in its own organization, and the
|
||||
tester only on a dev instance (Mos's reading, mos-1124 d).
|
||||
- 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).
|
||||
|
||||
## 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. The assistant and the chief of staff are
|
||||
> separate roles. No agent holds both, and the broker refuses a claim that would.
|
||||
> - **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". That is the assistant, a separate role from the chief of staff. Reading the two as one role
|
||||
would give grant rights to both and overlap their authority (Jason, Q191). So the assistant never raises
|
||||
a grant request: it passes the user's request to the chief (REQ-ASSIST-1) and can revoke a grant in its
|
||||
own organization (REQ-GRANT-4). Jason accepted this at Q191 ("Q13: that looks better").
|
||||
- What stops one holder claiming both roles today, at refactor 011d41ba6. Nothing names the pair. The
|
||||
separation holds by construction, and no check or test asserts it:
|
||||
- An instance has exactly one definition (`packages/business/src/business.mjs:118-120` in checkRoles).
|
||||
An assistant is an instance of the definition `assistant` (`packages/business/src/vocabulary.mjs:134`)
|
||||
and the chief of staff is an instance of `chief-of-staff` (`packages/bus/src/broker.mjs:164`, `:2137`).
|
||||
- A run binds one instance. `bindLaunch` takes one `role` (`broker.mjs:451-456`), refuses a second bind of
|
||||
the same run in the business (`duplicate-run`, `:479`), and refuses a rebind under another role
|
||||
(`launch-record-mismatch`, `:499`). The launcher spawns one process per launch and binds it to one
|
||||
instance (`packages/cli/src/launcher.mjs:438-449`).
|
||||
- `role.claim` claims only the run's own bound role (`broker.mjs:1689-1734`). An assistant run claims its
|
||||
thread in `thread_claims` and never writes `role_claims` (`:1693-1709`). The `role_one_holder` trigger
|
||||
(`packages/bus/src/migrations/3b.sql:117-119`) allows one claim per role, and nothing limits the roles
|
||||
one run holds, because a run has only one.
|
||||
Not enforced: the run key is per business (`broker.mjs:478`), so nothing stops one run id being bound
|
||||
in two businesses. Nothing ties two runs to two different processes or OS users beyond the launcher's
|
||||
own record (the B7 residual). `credential.mint` is an action (`broker.mjs:34`) and a hard wait
|
||||
(`:71`), but no check limits which role raises it. The landing step adds a broker refusal for a claim
|
||||
that would put both roles on one holder, with a test for each path above.
|
||||
- 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,202 +0,0 @@
|
||||
# Row 200: `stack-release.sh activate --sso` (Q175 phase 2 prerequisite)
|
||||
|
||||
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief
|
||||
`docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto
|
||||
the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5
|
||||
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2), sage-1556 (round 3: rebase onto row 190's
|
||||
landing 00d4cc1f6, and `--sso` for a named instance).
|
||||
|
||||
Base 00d4cc1f6 since round 3 (`base.txt`), row 190's landing, which has row 192 (4429a104f) below it. Rounds 1
|
||||
and 2 were built on 4d487dfdc: aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id
|
||||
8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches
|
||||
`launchpad/row192` at 37582e820 path for path. Rounds 1 and 2 are on `launchpad/row200`;
|
||||
round 3 is on `launchpad/row200-r3` (see Round 3 below).
|
||||
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
|
||||
round 3, origin/refactor is 00d4cc1f6 itself, so `git log 00d4cc1f6..origin/refactor` is empty.
|
||||
|
||||
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T57 runs on
|
||||
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
|
||||
and the slot carries the `proofFile` read watch, so a run that opened it would fail the test.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. |
|
||||
| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T57 (T56 before round 3), 11 subtests (2 in round 2, 1 in round 3). |
|
||||
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Item 1.** `activate ID BUSINESS --sso` drops `--public-host`, `--auth-header`, `--allow-user` and any proof
|
||||
pair, and puts `--sso` after the other flags, before `--send` and `--tester-port`, as `webui-service.sh`
|
||||
renders it (`sso_for_target`, `:775`). It may name the live copy once. After that, both a plain activate and
|
||||
an `--sso` activate refuse `already-active`. Later activates carry `--sso` (`sso: carried`; T57 covers
|
||||
activate, not `pm-on`, which carries the pointer's flags as it does for every flag).
|
||||
`--plan` prints `sso: on; drops ...; carries ...`, plus the proof line's `dropped; --sso drops header ...`.
|
||||
`--sso` with a pair, with `--no-proof` or on another verb is a usage error.
|
||||
2. **Item 2** (`sso_checks`, `:817`). These checks run whenever the run's Console flags hold `--sso`, before
|
||||
anything stops:
|
||||
- `sso-target`: the target's `cli.mjs` has no `--sso`.
|
||||
- `sso-config`: the target copy's own `checkConsole` refuses `console`, or there is no `console.sso`. The
|
||||
config is parsed in node, and only the message's first line is kept. A JSON parse error never quotes
|
||||
the text.
|
||||
- `sso-slot`: one per provider, from `lstat`, `stat` and `realpath` in `readSlot`'s order (`slot_problem`,
|
||||
`:847`). The slot is never opened.
|
||||
3. **Item 3.**
|
||||
- **Rollback** (`sso_base`, `:728`): from an `--sso` pointer to a copy without `--sso`, the Console flags
|
||||
come from the rollback set of the activation that moved the stack onto the pointer's copy from the
|
||||
target (`from_log arrival`). That set's `show-mosaic-webui.service.txt` is checked against its
|
||||
`SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
|
||||
- **`sso-rollback-flags`** refuses when that set is missing, fails its sums, holds no readable argv, or
|
||||
already ran `--sso`.
|
||||
- **`config-console-key`** (`config_checks`, `:866`): any activate or rollback refuses before anything
|
||||
stops when the config has a `console` key that the target's `scripts/mosaic-config.mjs validate` refuses.
|
||||
The message says to remove the key first (restore step 1's backup), then run the verb again.
|
||||
4. **N4, the first half** (sage-1403, sage-1407). With no `console.sso` and the stack on another copy, the
|
||||
`sso-config` refusal names the first step: "activate ID without --sso first, then write console.sso, then
|
||||
run activate ID --sso". A copy before row 192 refuses the key, so the re-pin comes first. The `--sso`
|
||||
activate is then the allowed same-copy re-pin.
|
||||
5. **TOOLS.md.** The flag, its refusals, the rollback rule and the N4 order.
|
||||
|
||||
## Window plan acceptance item (N4, mos-1056 item 5, sage-1407)
|
||||
|
||||
For the row 200 window plan, word for word:
|
||||
|
||||
- **Before the `console` key is written, the window re-pins to row 192 or later.** Check line, run before the
|
||||
key is written: `git -C /mnt/storage/src/mosaic-stack merge-base --is-ancestor <row 192 landing sha>
|
||||
<pointer copy sha from scripts/stack-release.sh status acme> && echo REPIN_OK`. It must print `REPIN_OK`.
|
||||
If the window instead runs `activate <ID> acme --sso --plan` before the key is written, the
|
||||
`refuses sso-config:` line must end with "activate <ID> without --sso first, then write console.sso, then run
|
||||
activate <ID> --sso". It names that step only when the pointer is on another copy.
|
||||
- **A rollback past row 192 removes the key first.** Check line, run before the rollback:
|
||||
`scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it
|
||||
prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself
|
||||
refuses on the same check, so a skipped step stops it before anything stops.
|
||||
- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso`
|
||||
off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed
|
||||
(the check line above).
|
||||
|
||||
## Tests run (this worktree, final tree)
|
||||
|
||||
- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree
|
||||
(`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`).
|
||||
- Round 3, this tree: `node --test tests/stack-release.test.mjs`, the whole file, 192/192
|
||||
(`/mnt/storage/scratch/launchpad-r200/sr-whole-r3b.log`); T57 alone 11/11 (`t57-r3a.log`);
|
||||
`tests/stack-instances.test.mjs` 23/23 (`si-r3.log`). No other test reads the 3 paths or pins their hashes.
|
||||
- The cli suite and every `scripts/test-*.sh` go to Sage's gate.
|
||||
|
||||
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, each against T57; T56 in rounds 1 and 2)
|
||||
|
||||
| # | Mutant | Result |
|
||||
| --- | --- | --- |
|
||||
| 1 | no `sso-target` refusal (item 2) | KILLED |
|
||||
| 2 | no `sso-config` refusal (item 2) | KILLED |
|
||||
| 3 | no `sso-slot` refusal (item 2) | KILLED |
|
||||
| 4 | no `sso-rollback-flags` refusal (item 3) | KILLED |
|
||||
| 5 | no `config-console-key` refusal (item 3) | KILLED |
|
||||
| 6 | keep `--auth-header` under `--sso` (item 1) | KILLED |
|
||||
| 7 | keep `--public-host` under `--sso` (item 1) | KILLED |
|
||||
| 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED |
|
||||
| 9 | `sso-config` names no first step (N4) | KILLED |
|
||||
| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
|
||||
| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
|
||||
| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run; equivalent if it survives, guard kept (sage-1446) |
|
||||
| 13 | `sso_checks` reads the default config, not `MOSAIC_CONFIG` (round 3) | not run, Sage's gate |
|
||||
| 14 | `sso_base` reads `show-mosaic-webui.service.txt`, not the instance unit's (round 3) | not run, Sage's gate |
|
||||
|
||||
9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it
|
||||
matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414),
|
||||
while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results
|
||||
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run. Sage runs 1 to 12 on the gate (sage-1446). Mutant 5's
|
||||
pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`.
|
||||
Every pattern matches once on the round 2 tree, and all 14 match once on the round 3 tree.
|
||||
|
||||
## Choices
|
||||
|
||||
- **R1, `--send` and `--tester-port` follow the pointer.** These two flags come from the pointer, not from the
|
||||
rollback set, so a `--no-send` or a tester port set after the arrival stays. This is row 189's rule that
|
||||
rollback carries `--send` off. Tradeoff: the rolled-back Console differs from the set's unit in those two
|
||||
flags, and the plan line says so.
|
||||
- **`config_checks` runs for activate too**, not only rollback. An activate onto a pre-192 copy with the key
|
||||
in place breaks that copy's loader the same way.
|
||||
- **The inside-repository slot test** symlinks the secrets directory to a temp dir under the fixture repo. The
|
||||
slot file is then not a link, but its real path is inside the repository.
|
||||
|
||||
## Not tested
|
||||
|
||||
- The slot owner-uid refusal: it needs a file owned by another uid, which needs privilege.
|
||||
|
||||
## Review r1 notes (webby-0028, non-blocking)
|
||||
|
||||
- **N1.** `slot_problem` (`:859`) and row 187's `proof_file_problem` (`:988`) compare a resolved path to
|
||||
`REPO="$(pwd)"` (`:26`), which is not resolved. Run through the `~/src/mosaic-stack-dev-test` symlink, a slot
|
||||
inside the repository passes here, and the Console refuses it after the stop. Confirmed by reading; Sage
|
||||
rules on a round 2 or a follow-up.
|
||||
- **N2.** `config_checks` (`:868`) returns 0 when config.json doesn't parse. Same ruling.
|
||||
- **N3.** A check this script doesn't make: `slot_problem` refuses only a size of 0. A slot holding only a
|
||||
newline, or two lines, passes here, and the Console's one-non-empty-line rule (`readSlot`) refuses it after
|
||||
the stop. Reading the content would break the rule that this script never opens the slot.
|
||||
- **N4, for the window's back-out step.** Once the live copy supports `--sso`, no verb turns `--sso` off on it.
|
||||
The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key
|
||||
first (the second check line above).
|
||||
|
||||
## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes)
|
||||
|
||||
- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with
|
||||
`realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T57's
|
||||
"round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo
|
||||
(`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`,
|
||||
and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository
|
||||
or the copies". Mutants 10 and 11 undo each check.
|
||||
- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse"
|
||||
(exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case
|
||||
doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that
|
||||
doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after
|
||||
`load_config` ran, and no test reaches it without a race. The T57 subtest "round 2 (webby-0028 N2)" checks
|
||||
what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load
|
||||
failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the
|
||||
config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. sage-1446:
|
||||
the guard stays, and mutant 12 is equivalent if it survives, the same call as row 192's mutant 23.
|
||||
- **N4.** The back-out line is in the window plan section above.
|
||||
|
||||
## Round 3 (sage-1556: rebase onto 00d4cc1f6, `--sso` for a named instance)
|
||||
|
||||
- **Rebase.** Row 190 landed as 00d4cc1f6 and changed all 3 paths. The 5 round 1 and 2 commits are picked onto
|
||||
it on a new branch, `launchpad/row200-r3` (no force-push, no branch delete, #426(a)). `launchpad/row200` stays
|
||||
as it was. `docs/TOOLS.md` and the test file applied cleanly. `scripts/stack-release.sh` conflicted in `plan()`
|
||||
and in the `--plan` lines of `do_activate` and `do_rollback`. The merge keeps both sides: the `sso:` plan line
|
||||
comes before the Console line, the Console and CLI lines carry row 190's `$flags` (`--instance NAME`), and
|
||||
`--plan` runs `ports_unclaimed` after `console_for_target` and before `plugin_checks` and `config_checks`.
|
||||
Functions on this tree: `sso_base` `:738`, `sso_for_target` `:785`, `sso_checks` `:827`, `slot_problem`
|
||||
`:857`, `config_checks` `:877`, `proof_file_problem` `:999`.
|
||||
- **Named instance: no code change was needed.** Row 190's `instance_select` exports `MOSAIC_CONFIG` as NAME's
|
||||
config, and the named Console unit sets `Environment=MOSAIC_CONFIG` to the same file. So `sso_checks` and
|
||||
`config_checks` read `~/.config/mosaic-NAME/config.json`, and the slot path is
|
||||
`~/.config/mosaic-NAME/secrets/mosaic-stack/<slot>`, the directory the Console's `readSlot` reads under that
|
||||
unit. `sso_base` reads `show-$CONSOLE_UNIT.txt`, so a named rollback reads `show-mosaic-webui-NAME.service.txt`
|
||||
from NAME's own set. The pointer, the log and the sets are in NAME's data root. One comment (`:734`) named the
|
||||
default unit's file and now says `show-$CONSOLE_UNIT.txt`.
|
||||
- **Test.** New T57 subtest "round 3 (sage-1556)": instance `alpha`. A missing slot refuses `sso-slot` and names
|
||||
alpha's slot path. `--plan` prints `--instance alpha` on the Console line. `activate C1 acme --sso` writes
|
||||
`--sso` to `mosaic-webui-alpha.service` (with `MOSAIC_CONFIG` set to alpha's config), leaves the default
|
||||
Console unit unwritten, and status matches. After the key is removed, `rollback` takes its flags from alpha's
|
||||
arrival set, `show-mosaic-webui-alpha.service.txt`. The slot carries the read watch. Mutants 13 and 14 undo
|
||||
the two instance-specific reads.
|
||||
- **Rename.** Row 190 also named its test T56, so this row's test is now T57; `mutants.py` runs `^T57`, and the
|
||||
in-repository temp dirs are `t57-slot-` and `t57-proof-`.
|
||||
- **Defect from round 1, fixed.** Round 1 added `| --sso` to the activate usage line, but T53 test 6 (row 187)
|
||||
and T55 test 5 (row 189) still matched the old line, so 4 tests failed (2 subtests and their parents). Rounds
|
||||
1 and 2 ran only this row's test, so the failure went unseen. The round 2 tree d4ec998cc fails the same 4
|
||||
(`r2-t53t55.log`), and the first whole-file run on this tree was 188/192 (`sr-whole-r3.log`). Both regexes now
|
||||
include `| --sso` (`:1672`, `:1831`; T57's own check is `:1940`). Webby: please check these two lines; the
|
||||
round 2 approve did not cover them (sage-1569). A grep of `packages/*/tests` and `scripts/test-*.sh` for the
|
||||
usage text (`--no-proof]`, `proof-file PATH`, `usage: stack-release`) finds no third copy, and finds no
|
||||
64-hex pin on any of the 3 paths. The only test files that name `stack-release` are `stack-release.test.mjs`
|
||||
and `stack-instances.test.mjs` (a comment); both ran as whole files (above).
|
||||
- **Not changed (note).** The N4 first step reads "activate ID without --sso first"; for a named instance the
|
||||
operator also passes `--instance NAME`, as with every verb. The message doesn't repeat it.
|
||||
|
||||
## Not done here
|
||||
|
||||
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
|
||||
@@ -1 +0,0 @@
|
||||
00d4cc1f6987d5eec340361c17efc2b3e58d1505
|
||||
@@ -1,3 +0,0 @@
|
||||
4bf241979d5cf2e83a2f209adbf3acb1a8ea091c5c384b27b5e3462fa348d9d0 docs/TOOLS.md
|
||||
959567d9bf6c2435640060fcb33890a344ead7f7cbc1f8019e45ae2498fcdd4e packages/cli/tests/stack-release.test.mjs
|
||||
63073fbea264549694b54cc1ae7d46ba9dbe8c5d72670da87985a5223c06ac3f scripts/stack-release.sh
|
||||
@@ -1,50 +0,0 @@
|
||||
# Row 200 (#1698) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
|
||||
# runs T57 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
|
||||
# Killed = test exit != 0.
|
||||
import hashlib, os, subprocess, sys, signal
|
||||
R = os.path.abspath(sys.argv[1])
|
||||
S = os.path.join(R, 'scripts', 'stack-release.sh')
|
||||
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
|
||||
T, PAT = 'tests/stack-release.test.mjs', '^T57'
|
||||
M = [
|
||||
('no sso-target refusal (item 2)', [(" sso_supported || { check_refuse sso-target", " true || { check_refuse sso-target")]),
|
||||
('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]),
|
||||
('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]),
|
||||
('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]),
|
||||
('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]),
|
||||
('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]),
|
||||
('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]),
|
||||
('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]),
|
||||
('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]),
|
||||
('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]),
|
||||
('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]),
|
||||
('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]),
|
||||
('sso_checks reads the default config, not the instance config (round 3, sage-1556)', [(' local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why', ' local cfg="$HOME/.config/mosaic-dev/config.json" out id slot path why')]),
|
||||
('sso_base reads the default Console\'s show file, not the instance unit\'s (round 3, sage-1556)', [(' local f="show-$CONSOLE_UNIT.txt" argv=""', ' local f="show-mosaic-webui.service.txt" argv=""')]),
|
||||
]
|
||||
sha = lambda b: hashlib.sha256(b).hexdigest()
|
||||
only = sys.argv[2:]
|
||||
os.chdir(os.path.join(R, 'packages', 'cli'))
|
||||
survived = []
|
||||
for i, (name, pairs) in enumerate(M, 1):
|
||||
if only and str(i) not in only: continue
|
||||
orig = open(S, 'rb').read(); h = sha(orig)
|
||||
src = orig.decode()
|
||||
for a, b in pairs:
|
||||
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
|
||||
src = src.replace(a, b)
|
||||
try:
|
||||
open(S, 'w').write(src); os.chmod(S, 0o755)
|
||||
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: out, _ = p.communicate(timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
|
||||
text = out.decode(errors='replace')
|
||||
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
|
||||
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
|
||||
if verdict == 'SURVIVED': survived.append(name)
|
||||
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
|
||||
finally:
|
||||
open(S, 'wb').write(orig); os.chmod(S, 0o755)
|
||||
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
|
||||
print('survived:', survived or 'none')
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: 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, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: 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 role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
[
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: 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, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: 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 role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
[mos test] Please have the PM raise a prd.approve decision for PRD 1.8, the same route as PRD 1.7 (decision d52febb5): blocking false, no task_ref, recommendation yes, gated to the human. The PM must not compute or change the sha256; it copies the values below.
|
||||
|
||||
Question: "Approve PRD 1.8 exactly as drafted?"
|
||||
File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Reviewed by Duckworth (duckworth-0061, confirmed duckworth-0062) and checked by Mos (mos-1140). The branch tip of honker/row194-b2 holds a later r6 (aba3cbdeb) that is not used; the approval covers only the r5 commit and sha256 above.
|
||||
|
||||
The decision notes must carry these five items:
|
||||
1. Mos's reading: "the main agent or chief of staff" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.
|
||||
2. Mos's reading: revoke by "you, Mos or the tester" means the user, the assistant ("Mos", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.
|
||||
3. N5: 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, not to the user. In manual mode it comes to the user.
|
||||
4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.
|
||||
5. N6: 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 role contracts, not a broker check.
|
||||
|
||||
Reply with the decision id once raised. Do not resolve it.
|
||||
@@ -1,8 +0,0 @@
|
||||
sent d54a190e to chief
|
||||
chief (RESULT, re d54a190e) cc46a7b4:
|
||||
I passed your request to the pm without changing any of it, and launched the pm so it can act on it. No decision id yet, because the pm hasn't raised the decision.
|
||||
|
||||
- **Message to pm:** 6990efb4-8321-4d0e-9b8a-771f65625b83. It says the request comes from a tester and authorizes nothing, and it carries your PRD 1.8 values and the five notes as you wrote them.
|
||||
- **pm launch:** run rc9e993da24cf.
|
||||
|
||||
When the pm answers with the decision id, I'll send it to you.
|
||||
@@ -1,5 +0,0 @@
|
||||
trail 3a6c8fb9-a5ab-42f8-9755-902a88375ca2: 4 row(s)
|
||||
2026-10-20T22:40:47.949Z events#1550 session.launched pm
|
||||
2026-10-20T22:40:47.969Z role_claims#102 claim pm by rc9e993da24cf
|
||||
2026-10-20T22:41:14.444Z decisions#22 gated prd.approve → human: Approve PRD 1.8 exactly as drafted? File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit …
|
||||
2026-10-20T22:41:14.445Z events#1553 action.allowed pm decision.raise decision 3a6c8fb9
|
||||
@@ -26,8 +26,7 @@ the rollback compare are reused unchanged.
|
||||
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
|
||||
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
|
||||
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`).
|
||||
The 106 that `status` showed is OLD to HEAD 305857c8f, so it also counts the three record commits below.
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 106 commits past OLD counting records.
|
||||
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
|
||||
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
|
||||
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
|
||||
@@ -80,7 +79,7 @@ writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4weba
|
||||
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
|
||||
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
|
||||
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. Correction (mos-1107): `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. So Mos also starts NEW's Console by hand with the pointer's flags on two scratch ports and reads healthz and `/api/config` on both, then stops it (`$WIN/p3/flags-check.txt`, `flags-console.log`). |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's, with the pointer's Console flags. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. |
|
||||
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
|
||||
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
|
||||
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
|
||||
@@ -96,7 +95,7 @@ writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4weba
|
||||
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
|
||||
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. Corrections after the window (mos-1135, mos-1137): the matrix line for a file the pin changes isn't "identical". Line (g) serves `index.html`, which row 192 grows by 121 bytes (6470 to 6591), so (g) is 200 non-json at 6575 bytes, not 6454. The two browser checks come from the 177-185 window, before row 192 and before a second thread project. "Only tester forms" now allows `#sign-out` while it is `hidden` and `/api/config` has no `sso: true`. "Chat lists the W9 thread" now reads every `.chat-projects section.panel` list (chat.js:93 renders one per project), not the first. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. |
|
||||
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
chief rbb708846b605 claude-code since 2026-10-20T19:29:59.053Z
|
||||
pm rc36b17ef3788 claude-code since 2026-10-20T20:20:56.278Z
|
||||
# exit 0
|
||||
@@ -1,256 +0,0 @@
|
||||
{
|
||||
"settings loaded": {
|
||||
"pass": true
|
||||
},
|
||||
"settings": {
|
||||
"banner": "Tester Console. Changes are recorded as tester, never as the human.",
|
||||
"h1": "Settings",
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
],
|
||||
"convFormHidden": true,
|
||||
"panels": [
|
||||
"Routing",
|
||||
"Role instances",
|
||||
"Seat names"
|
||||
],
|
||||
"instancesTable": true,
|
||||
"nameForms": 6,
|
||||
"routesForm": true,
|
||||
"routesSubmitEnabled": true,
|
||||
"forbidden": [],
|
||||
"tokenLimitsPanel": false
|
||||
},
|
||||
"tester banner": {
|
||||
"pass": true,
|
||||
"detail": "Tester Console. Changes are recorded as tester, never as the human."
|
||||
},
|
||||
"instance, routes and name editors present": {
|
||||
"pass": true
|
||||
},
|
||||
"only tester forms (conv-form is index.html:21, global and hidden)": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
]
|
||||
},
|
||||
"no launch settings, assistant, limit reset or send": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no Token limits panel (plan W10 item 2, amended)": {
|
||||
"pass": true
|
||||
},
|
||||
"board": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
},
|
||||
"board reads": {
|
||||
"pass": true,
|
||||
"detail": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"banner": true,
|
||||
"threads": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
],
|
||||
"conversationOpen": false,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"chat-project-form"
|
||||
]
|
||||
},
|
||||
"chat lists the W9 thread": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
]
|
||||
},
|
||||
"W9 thread page not opened": {
|
||||
"pass": true,
|
||||
"detail": "#/chat"
|
||||
},
|
||||
"tester banner on chat": {
|
||||
"pass": true
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/inbox",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/agents",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
],
|
||||
"no write through the listener": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no request to the W9 thread messages or open": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
}
|
||||
}
|
||||
W10 browser: FAIL
|
||||
# exit 1
|
||||
@@ -1,200 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"sso": null
|
||||
},
|
||||
"forms": [
|
||||
{
|
||||
"id": "sign-out",
|
||||
"hidden": true,
|
||||
"action": "/auth/logout",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"id": "conv-form",
|
||||
"hidden": true,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "routes-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-add-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-change-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
}
|
||||
],
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"open": false,
|
||||
"projects": [
|
||||
{
|
||||
"project": "window-20261020-0801",
|
||||
"threads": [
|
||||
"#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "window-20261020-2205",
|
||||
"threads": [
|
||||
"#/chat/threads/446201de-e8b5-4df3-9356-d0d5422cc899"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
]
|
||||
}
|
||||
sign-out hidden true, sso null; W9 thread listed true; thread page open false; writes 0
|
||||
# exit 0
|
||||
@@ -1,8 +0,0 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
# exit 0
|
||||
@@ -1,7 +0,0 @@
|
||||
# P4 2026-10-20T22:31:29Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
# exit 0
|
||||
@@ -1,6 +0,0 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950923
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950923 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -1,12 +0,0 @@
|
||||
● jarvis-curation-gate.service loaded failed failed Jarvis curation-gate liveness check (wake the agent if staged pulls sit uncurated)
|
||||
● jarvis-retro-check.service loaded failed failed Jarvis weekly retrospective ARTIFACT check (did Sunday's tick produce a retro?)
|
||||
● jarvis-retro.service loaded failed failed Jarvis weekly retrospective tick
|
||||
● mosaic-install-drift.service loaded failed failed Assert the installed Mosaic binaries still match the committed source (drift, not failure)
|
||||
● mosaic-standup-pm.service loaded failed failed Coordinator standup trigger (evening)
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-curation-gate.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro-check.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-install-drift.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-reconcile.service to the local agent seat
|
||||
● setspark-tmp-guard.service loaded failed failed SetSpark: /tmp and scratch guard
|
||||
# exit 0
|
||||
@@ -1,5 +0,0 @@
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
ActiveEnterTimestamp=Tue 2026-10-13 08:40:19 CDT
|
||||
MainPID=1317
|
||||
# exit 0
|
||||
@@ -1,6 +0,0 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950925
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950925 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -1,2 +0,0 @@
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
@@ -1,7 +0,0 @@
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
@@ -1,19 +0,0 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -1,2 +0,0 @@
|
||||
[]
|
||||
# exit 0
|
||||
@@ -1,2 +0,0 @@
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
@@ -1 +0,0 @@
|
||||
1792533965
|
||||
@@ -1,21 +0,0 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
@@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
@@ -1,3 +0,0 @@
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
@@ -1 +0,0 @@
|
||||
446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
@@ -1,165 +0,0 @@
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
@@ -1,2 +0,0 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -1,19 +0,0 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1726,
|
||||
"maxSeq": 1726,
|
||||
"sha256": "8667571f4a05ce3621b04cf2dc5eb0311bac3d3aff1858584c78445c5c4c2fb4"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1525,
|
||||
"maxSeq": 1525,
|
||||
"sha256": "e51827e31f2fa5a9a50993e5a10f37f1dee383b7d673f793006bd2ba1e9a5573"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 567,
|
||||
"maxSeq": 567
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "a3945f7462895e0e302029c542d384a6d33e559da900e6461d78bbcd7cc28b16"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 98,
|
||||
"maxSeq": 98,
|
||||
"sha256": "ac6694fd0b365469f7486ae1f31486cd52b31be7bc0399938b7361f22454362e"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 215,
|
||||
"maxSeq": 215,
|
||||
"sha256": "7f80df8efb90b502a9530a7ed5068d59be59b2eb318aa4e64ce6bf481762a5e9"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 401,
|
||||
"maxSeq": 401,
|
||||
"sha256": "cced8c7f90ecaa60eee3010d987013d8c0301563cb888860fd91b8c5a02f0a00"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "6046c7088779aa2f6e9b3fd2c7520404b1678a4fcae71c2b9157a008bba22a5f"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "3043721efd386f23dfdaad4acf41e0d553ff6182f0068c2133a615141bcf772a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 30,
|
||||
"maxSeq": 30,
|
||||
"sha256": "7ecbc0606273043b017d778bb23880377bfe367f788d234289b7c132caa588f2",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
# Re-pin window 490e2771d to 4429a104f (row 192, Q175 phase 1), STATE report (W11)
|
||||
|
||||
Operator and tester: Sage. Plan: `agents/sage/work/window-plan/repin-4429a104f.md` at f41729178, sha256
|
||||
e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9 (checked before W0b, W9). The plan file in
|
||||
this commit carries three corrections made after the window. They are listed at the end, so its sha256 differs
|
||||
now. Tool blob 3b068b1546d4 (`scripts/stack-release.sh`). OLD 490e2771d3d057ab76991e2c7859cd251857e64c, NEW
|
||||
4429a104f6d65ccf165f6c83b5d1dce3147dfde0. Every stamp below comes from `date -u` or from the transcript `W.txt`
|
||||
in this directory. Mos's gos: mos-1113 (W5), mos-1114 (W9 to W11), mos-1128 (W9b accepted), mos-1135 (W10
|
||||
browser part), mos-1137 (line g, post W11).
|
||||
|
||||
## Outcome
|
||||
|
||||
NEW is live for mosaic-stack with its PM, and the store stays on 3i. Row 192's sign-in code is in the copy but
|
||||
dormant, because the Console runs without `--sso`. W0 to W10 passed, and nothing was rolled back. W10 stopped
|
||||
once on two failed browser checks. I diagnosed both read-only as checks written before row 192, not faults in
|
||||
NEW, and Mos passed them (mos-1135). Matrix line (g) isn't byte-identical, for a known cause, and Mos passed it
|
||||
(mos-1137). Nobody acted as Jason. There was no `--by jason`, no pi, no Save and no `mosaic name`, and
|
||||
`bus.tester` was not touched.
|
||||
|
||||
## Stamps and activation
|
||||
|
||||
| Step | Start (UTC) | Result |
|
||||
| --- | --- | --- |
|
||||
| P1 values, business-check OLD | file written 21:43:13Z | values as planned, LOAD_OK (`p1.txt`) |
|
||||
| W0 copy NEW with npm ci, business-check NEW | 21:45:57Z | copied, lockfile diff empty, LOAD_OK (`w0.txt`) |
|
||||
| P2 console-check on a stub of NEW | 21:46:09Z | CONSOLE_PASS without and with `--tester-port` (`p2.txt`) |
|
||||
| P3 rehearsal (Mos) | 21:52:31Z to 21:52:34Z | REHEARSAL_PASS, rc 0 (`p3/`) |
|
||||
| P3 flags check (Mos) | 21:53:52Z to 21:53:56Z | NEW Console with the pointer flags on 7341/7342: healthz 200 both, then gone, 0 listeners |
|
||||
| P4, P5, P6 | 22:00:17Z | P4 PASS, P5 PASS (HEAD 4385b9ec1), `matrix-old.txt` |
|
||||
| W0b business file copy | 22:00:49Z | sha256 1e49bdf0 |
|
||||
| W1 idle check, S1 | 22:00:49.758Z | chief idle; S1 a7649760 |
|
||||
| W2 status | 22:00:49.819Z | pointer OLD, pm true, 3i, unit flags match |
|
||||
| W3 plan | 22:00:50.068Z | exit 0 (`W3-plan.txt` 45de68d4) |
|
||||
| W4 unit read | 22:00:50.238Z | OLD units, no drop-ins, bus `--pm`, no quote or backslash |
|
||||
| W5 activate (mos-1113) | 22:02:06.776Z, end 22:02:09.556Z | exit 0, gate passed, activate ok |
|
||||
| W5 status | 22:02:13.885Z | pointer NEW, pm true, 3i, unit flags match |
|
||||
| W9 checks and turns (mos-1114) | 22:05:51Z to 22:06:06Z | pass |
|
||||
| W9b run end and S2 | 22:21:05.171Z, S2 22:21:07Z | ended by the launcher (thread-idle); S2 1a444f5a |
|
||||
| W10 browser (stop), diagnosis | 22:29:14Z, 22:30:02Z | 10 of 12, then both failures traced to the checks |
|
||||
| W10 matrix, P4 (mos-1135) | 22:31:29Z | a-f identical, g +121 bytes; P4 PASS |
|
||||
| W11 reads | 22:44:33Z to 22:44:34Z | units, tunnel, business file, failed units |
|
||||
|
||||
Activation id 20261020T220206Z-4429a104f6d6. Pointer `at` 2026-10-20T22:02:09Z.
|
||||
|
||||
## Backups and hashes
|
||||
|
||||
- Store backup: `~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6`, SHA256SUMS sha256
|
||||
b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9.
|
||||
- Rollback set: `~/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6`. Rollback goes to 490e2771d.
|
||||
- Business file: 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 at P1, at W0b and again at
|
||||
W11. The W0b copy is `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z` with the
|
||||
same hash. Nothing in the window wrote it. `backups.txt` holds the W0b and W5 lines.
|
||||
|
||||
## Pointer before and after
|
||||
|
||||
Before (W2): copy 490e2771d3d0, pm true, schema 3i, activation 20261020T192956Z-490e2771d3d0. Console flags
|
||||
`--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje
|
||||
--tester-port 7333`.
|
||||
|
||||
After (W5, W9 and W11): copy 4429a104f6d6, pm true, schema 3i, activation 20261020T220206Z-4429a104f6d6. The
|
||||
Console flags are the same, with no `--send`, no `--sso` and no proof pair. Bus pid 2950923 and Console pid
|
||||
2950925, both since 22:02:08Z. Status shows drift of 6 commits on refactor since the pointer's copy (records).
|
||||
|
||||
## Unit ExecStart lines (no Environment values)
|
||||
|
||||
W11, after. Both units active, DropInPaths empty, WorkingDirectory the NEW copy:
|
||||
|
||||
```
|
||||
bus: ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm (MainPID 2950923)
|
||||
webui: /usr/bin/node ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 (MainPID 2950925)
|
||||
```
|
||||
|
||||
W4 read the same two lines on OLD's copy (W.txt 329-344). The bus Environment has 2 assignments with no quote or
|
||||
backslash. The values are not recorded. The Console's cwd is the NEW copy (W9). The webui journal since W5 has one
|
||||
"Proxy: proxy proof off" line and no "Sign-in:" line.
|
||||
|
||||
Tunnel: `mosaic-preview-tunnel.service` active, running, MainPID 1317, since 2026-10-13 08:40:19 CDT. The window
|
||||
didn't touch it. P4 before and after shows one R: spec, `R:0.0.0.0:7332:127.0.0.1:7332`.
|
||||
|
||||
## S1, S2 and the store
|
||||
|
||||
S1 (`S1.json`, sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b) is W1's held read at
|
||||
22:00:49Z. S2 (`agents/sage/work/window-repin-4429a104f/S2.json`, sha256
|
||||
1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173) is the held read after the assistant run
|
||||
ended, at 22:21:07Z. Both are 3i. SNAP (`snapshot.mjs`, 639fd28a) was checked before each.
|
||||
|
||||
| Table | S1 rows | S2 rows | S2 vs S1 |
|
||||
| --- | --- | --- | --- |
|
||||
| decisions, decision_events | 21, 21 | 21, 21 | same sha256 |
|
||||
| meta, instance_names, thread_opens | 3, 0, 0 | 3, 0, 0 | same sha256 |
|
||||
| deliveries | 1726 | 1740 | +14 |
|
||||
| events | 1525 | 1543 | +18 |
|
||||
| role_claims | 98 | 101 | +3 |
|
||||
| messages | 567 | 571 | +4 (counts only) |
|
||||
| message_threads | 2 | 4 | +2 (counts only) |
|
||||
| usage_records | 30 | 34 | +4 |
|
||||
| projects, threads, thread_claims | 1, 1, 2 | 2, 2, 4 | +1, +1, +2 |
|
||||
| task_snapshots | 401 | 408 | +7 |
|
||||
| task_comment_cursors | 215 | 220 | +5 |
|
||||
|
||||
For every grown table with a seq, maxSeq grew by exactly the row delta.
|
||||
|
||||
The task_snapshots and task_comment_cursors rows (mos-1128 item 2). The writer is the bus's own Vikunja poll, not
|
||||
an agent. `packages/bus/src/process.mjs:104` loads the tasks adapter. Its timer runs `tick` every `pollSeconds`
|
||||
(`packages/tasks/src/adapter.mjs:192-197`), and `sync.mjs:169` calls `broker.recordTask` with no cap
|
||||
(`packages/bus/src/broker.mjs:1123`). That writes `source 'poll'` with role and run null (`broker.mjs:1159-1174`)
|
||||
and the comment cursors (`:1131-1138`). The adapter logs only on an error (`adapter.mjs:115`), so the bus journal
|
||||
has no line for a good tick. That's why there's no sync line between 22:05Z and 22:22Z. The rows, read
|
||||
read-only after the window:
|
||||
|
||||
- task_snapshots 402-404 at 22:05:08Z: P19 436, 435 and 434, all updated 22:04:55-56Z. 405-406 at 22:12:38Z: 438
|
||||
and 437. 407 at 22:17:08Z: 438 again. 408 at 22:19:08Z: 431. All have `source poll`, `via cursor`, role null
|
||||
and run null. These are tracker edits made by seats during the window, which the poll picked up.
|
||||
- task_comment_cursors 216-220: tasks 424, 432, 438, 431 and 436, last comments 397 to 401. These are the
|
||||
comments seats posted on P19 in the same minutes (398 is Mos's comment on #432).
|
||||
|
||||
Rows after S2 (task_snapshots 409 at 22:33Z, cursors 221-223) are the same poll and are outside the window.
|
||||
|
||||
## W9 output
|
||||
|
||||
- Status, console-check (CONSOLE_PASS), the journal counts, the Console cwd and one running chief from this
|
||||
start (r097436c91816) all passed.
|
||||
- `talk chief --tester` (c5b43f04): the chief replied that a tester message authorizes nothing and it took no
|
||||
action.
|
||||
- Project `window-20261020-2205`, thread 446201de-e8b5-4df3-9356-d0d5422cc899. `talk --thread` (b4b66ced) got one
|
||||
assistant reply from run r497f1c817d4d. `thread read` shows the tester REQUEST and the assistant RESULT.
|
||||
- `threads`, `names` (empty) and `usage` read fine. The human-thread probe was skipped: S1's one thread is the
|
||||
177-185 window's tester thread d0ba7a66, so no human thread exists.
|
||||
- W9b: the launcher logged launch at 22:06:00.766Z, thread-idle at 22:21:05.149Z and end at 22:21:05.171Z
|
||||
(reason stopped, exit 0). Nobody stopped it by hand.
|
||||
|
||||
## W10
|
||||
|
||||
The browser check (`w10-browser.mjs`, dda57ccf) ran against 127.0.0.1:7333 with fetch and XHR limited to GET and
|
||||
HEAD. It made no writes and sent no W9 thread request. 10 of 12 checks passed. The two that failed:
|
||||
|
||||
1. "Only tester forms": `#sign-out` was in the list. It is row 192's form at `packages/webui/src/public/index.html:12`,
|
||||
`hidden`. `app.js:479` shows it only when `/api/config` has `sso: true`. The live config has no `sso`
|
||||
key, and the form stayed hidden.
|
||||
2. "Chat lists the W9 thread": the check read only the first `ul.chat-thread-list`. `chat.js:93` renders one list
|
||||
per project, and W9 made the second project. The diagnosis (`w10-diag.mjs`, 42d0844b) read every panel and
|
||||
found 446201de listed.
|
||||
|
||||
Both checks come from the 177-185 window (Darkwing's `window-rows-177-185/w10-browser.mjs`), written before row
|
||||
192 and before a second thread project existed. Corrected, they now read:
|
||||
|
||||
- Only tester forms, allowing `#sign-out` when it is `hidden` and `/api/config` has no `sso: true`.
|
||||
- The W9 thread is in some `.chat-projects section.panel` thread list, not only the first.
|
||||
|
||||
Both corrections are in the plan's W10 line, for the next window that copies this one.
|
||||
|
||||
The matrix (`matrix-new.txt` against `matrix-old.txt`). Lines a to f are byte-identical. Line g is 200 non-json
|
||||
both times, but 6454 bytes on OLD and 6575 on NEW. `index.html` is 6470 bytes at 490e2771d and 6591 at 4429a104f,
|
||||
+121, and the only hunk is the line 12 form. The served page grew by the same 121. The plan's W10 line now says a
|
||||
line for a file the pin changes isn't "identical", with this case as its example. P4 again at 22:31:29Z: PASS.
|
||||
|
||||
## Failed user units (mos-1128 item 3)
|
||||
|
||||
All three predate the re-pin at 22:02Z and come from the legacy `~/.mosaic` and jarvis-brain setup, not the
|
||||
stack. P19 #393, "dragon-lin: seven legacy user units in failed state", covers all three (filed by Mos on
|
||||
10-20 at about 01:42Z). It has no comments yet. The current failure times are later than the ones #393 lists,
|
||||
so I added a comment with them. Times are from `systemctl --user show` (CDT) with UTC alongside.
|
||||
|
||||
| Unit | Runs | Failed | #393 |
|
||||
| --- | --- | --- | --- |
|
||||
| mosaic-standup-pm.service | `~/.mosaic/tools/coordinator/standup_trigger.py pm` | 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1 | listed |
|
||||
| mosaic-install-drift.service | `~/src/jarvis-brain/scripts/alerting/mosaic-install-drift` | 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9 | listed, with an older time |
|
||||
| mosaic-unit-failed@mosaic-reconcile.service | `~/.local/bin/mosaic-unit-failed mosaic-reconcile.service` | 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4 | listed, with an older time |
|
||||
|
||||
The marker's journal at 18:16:12Z says the escalation's tmux send to jarvis failed ("tmux target not found",
|
||||
socket mosaic-fleet), so the page wasn't delivered. The record is on disk in
|
||||
`~/.local/state/mosaic-unit-failures/20261020.jsonl`. `mosaic-reconcile.service` failed at that time (exit 7)
|
||||
but last ran OK at 17:16:27 CDT (22:16:27Z). The marker stays failed until something resets it. The journal no
|
||||
longer has lines for the other two units. The window changed none of these units, and touching them is
|
||||
outside it (`~/.mosaic` is off limits, and #393 needs Jason or ops-01's charter to disable any). W11's list also
|
||||
shows `setspark-tmp-guard.service` failed. It belongs to another project, and I've left it.
|
||||
|
||||
## Corrections to the plan, made after the window
|
||||
|
||||
- NEW is 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`), not 106. The 106 in `status` was OLD
|
||||
to HEAD 305857c8f, so it also counted the three record commits.
|
||||
- P3: `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. Mos's
|
||||
separate flags check covers that (`p3/flags-check.txt`, `p3/flags-console.log`). The line was wrong as
|
||||
written (mos-1107).
|
||||
- W10: the matrix line g size change and the two corrected browser checks, as above.
|
||||
|
||||
## Files
|
||||
|
||||
`W.txt` (transcript), `S1.json`, `S2.json`, `matrix-old.txt`, `matrix-new.txt`, `backups.txt`, `W3-plan.txt`,
|
||||
`W5.txt`, `W5-status.txt`, `w0.txt`, `p1.txt`, `p2.txt`, `p3/` (Mos's rehearsal and flags check), `p456.sh`,
|
||||
`w9.sh`, `w9b.sh`, `w9.out`, `w9b.out`, `w10-browser.mjs`, `w10-diag.mjs` with their outputs (the three
|
||||
screenshots stay on disk in this directory, uncommitted, since the push check refuses binaries),
|
||||
and the `.w9*`, `.w10*` and `.w11*` reads. The store snapshot tool is `snapshot.mjs` (639fd28a).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
||||
plan: activate for mosaic-stack
|
||||
from: 490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
to: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
store schema: 3i (read from the store the running bus holds)
|
||||
target schema: 3i
|
||||
migration: none; the store is at the target's version
|
||||
backup: the run backs up the stopped store to /home/jwoltje/.mosaic-dev/backups/stack/<activation>/ before any migration
|
||||
commits: the run adds 103 (newest first, at most 200):
|
||||
4429a104f webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
|
||||
32b54ac3b plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names)
|
||||
6025ddd9c plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse)
|
||||
ecbfd6deb plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh)
|
||||
0498c0613 agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416)
|
||||
49d1f3a23 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27)
|
||||
628e2ca5c sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189)
|
||||
088087ac9 queue: revs 2248-2361+. Sage: rows 210 (#424 host access, Gizmoduck) and 211 (#426 force-push guard, Rocko) added and briefed; row 195 re-briefed to the Case 1 install on .53; review requests for 200 r1/r2 and 202; plus earlier Sage revs. Other seats' revs: Launchpad 2254, 2283-2284, 2286, 2311-2313, 2328-2329, 2344, 2347-2348 (rows 192/200); Honker 2255; Morgana 2257, 2320, 2326-2327 (row 204); Gander 2262; Herb 2265; Dewey 2268; Bushroot 2273; Megavolt 2276; Webby 2303, 2316, 2339, 2355 (row 192/200 verdicts); Gizmoduck 2304, 2318-2319 and the row 210 claim (rows 201/210); Quackerjack 2317, 2341-2342 (row 202); Duckworth 2323, 2335, 2353 (rows 201/204/202 verdicts); Darkwing 2354, 2357 (row 190 in-review)
|
||||
a0fbefd48 plans: pre-push force-push hook and failing busy check brief (Vikunja #426, mos-1078, mos-1083; owner Rocko, reviewer Dewey)
|
||||
f24f2cd40 plans: row 195 brief becomes the Case 1 install on 10.1.1.53 (Q181 hosts, Q182/Q183 recorded acceptance, Q185 existing account, chained on rows 190 and C2-13; ops-01 root steps under #423 step 1; sage-1439)
|
||||
9ba31b5b3 agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420)
|
||||
9db578ff5 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199)
|
||||
8eb9ddd12 plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1)
|
||||
ae3c36f24 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414)
|
||||
a2af7ab49 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row
|
||||
aa1bd209e gate-lane: row 189 gate timing table for row 197 (Herb)
|
||||
ace27dc5e row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed)
|
||||
a4a42dbac plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192)
|
||||
a31f1a0f8 plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019)
|
||||
992650232 plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir
|
||||
a7661ada0 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011)
|
||||
f871cb309 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir
|
||||
0bbd23c61 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008)
|
||||
0217b71c8 plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005)
|
||||
c6f528a32 plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row
|
||||
96f243faf plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b)
|
||||
c86269935 sessions: Filbert row 190 design review r3, approved
|
||||
48ebee0ff plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986)
|
||||
66bdd84b8 sessions: Filbert row 190 design review r2, changes requested
|
||||
6966164f0 plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986
|
||||
53bb59462 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981)
|
||||
5e01c3683 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985)
|
||||
9f56d2098 plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981)
|
||||
0599b119f plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977
|
||||
b416d2ede sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window
|
||||
5fc73a729 revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko)
|
||||
99772421a sage: row 189 candidate manifest for Dewey's review (d6116771f)
|
||||
da230ca48 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974
|
||||
4f2d1be68 sessions: Filbert Q175 SSO design D1/D2 ack
|
||||
d55d86bb0 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer)
|
||||
c56ccf916 sessions: Filbert Q175 SSO design review r3, approve with D1
|
||||
817e8f51b sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E
|
||||
5d019d876 sessions: Filbert Q175 SSO design review r2, changes
|
||||
59475fc5a sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row
|
||||
1a268249e sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973)
|
||||
def2e7932 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413)
|
||||
c30b36fff sessions: Filbert Q175 SSO design review r1, changes
|
||||
9ae8c73ac queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done
|
||||
8ecc7a239 row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
|
||||
7f57419b3 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)
|
||||
4ca8d6e94 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969)
|
||||
22e5e825b sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968)
|
||||
e6dea74b2 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record
|
||||
cd2b377bb queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596)
|
||||
ceccb0ef2 sessions: Filbert row 163 r2 review, approve
|
||||
10ab8ea62 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record
|
||||
c539995cf queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596)
|
||||
92cc6011c queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb)
|
||||
7af166574 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed)
|
||||
2244e8baf sessions: Filbert row 163 r1 review, changes
|
||||
bde0fdec9 sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record
|
||||
def677bc7 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb)
|
||||
51f67f1f8 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests)
|
||||
5b298bd00 sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews
|
||||
565bc32c2 queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve)
|
||||
5140aa9e8 sessions: Filbert row 66 r2 review, approve
|
||||
973cb5a87 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1)
|
||||
b82ec9353 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun)
|
||||
3560c450a sessions: Filbert row 66 r1 review, changes
|
||||
4f02486d5 sage: row 66 r1 packet (candidate 8ec03b45a: diff, manifest e68c6bce5ec4, gate r2), row 42 run log (trigger (a), mos-0960), SESSIONS line
|
||||
263ea8a9a queue: revs 2215-2219: row 66 Sage start (2215), in-review round 1 on candidate manifest e68c6bce5ec4 (8ec03b45a) and request comment 29437 (2216-2217); row 163 brief re-pinned to b1500d96ad27 (6020bb43a) and briefed (2218-2219)
|
||||
6020bb43a plans: D2 brief owner is sage (row 163, #1660), and D2 lands after the Part B window (mos-0958, mos-0959)
|
||||
5487ed39d sage: row 188 landing record: P19 411 (N1) and comments 361-363 script and record, 409/410 description restore after a partial done update, SESSIONS line
|
||||
31e328958 queue: revs 2209-2214: row 188 Darkwing start, in-review and request 29432 (2209-2211); Filbert approve 29433 (2212); Sage landing note (8fc0ee216, patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2, gate passed) and done (2213-2214)
|
||||
8fc0ee216 row 188 (#1686, P19 409, 410): stack-release T54, the proof file checks on a carried pair and a proof file inside the repository (Darkwing; Filbert approve 29433; Sage gate passed)
|
||||
23a609c5c sessions: Filbert row 188 r1 review, approve
|
||||
b9c1c9fe2 sage: Part B draft 2 A2 passed (mos-0958), row 188 launched, Vikunja comments 358-360 script and record, SESSIONS line
|
||||
525990ecb queue: revs 2207-2208: row 188 add and briefed (Sage), #1686, P19 409/410, gates Part B (mos-0956)
|
||||
148a9503d sage: Part B window file draft 2 for mos-0957 (F1: B6 and B-R1 name the stop's endLaunch rows, PB5 counts per step; F2: PB6, PB7 and B-R1 matrices run (a)-(h)); Q2 answered, Q1 to Jason's list
|
||||
240f21650 brief: stack-release tests for a carried proof pair and a proof file inside the repository (P19 409, 410, #1686, mos-0956)
|
||||
742e98976 sage: Part B window file, the read window's end stamp from the clock (10:29Z, not 10:40Z)
|
||||
288ab1b5a sage: Part B window file for Mos's A2 check (live-copy B3 and B-R1, Q1 on J2 order), SESSIONS lines for Darkwing (row 187, 10:25:22Z) and Sage
|
||||
a843ae284 sage: row 187 landing, P19 407-410 filed and P19 385 comment 357, #1685 closed (comment 29425), SESSIONS line
|
||||
652d6f3a8 queue: revs 2201-2206: row 187 Darkwing start, in-review and request comment 29423 (2201-2203); Filbert approve comment 29424 (2204); landing note (9a71e4e80, patch-id 6b57c47bc661, gate passed) and done (Sage 2205-2206)
|
||||
9a71e4e80 row 187 (#1685): activate sets, carries and drops the Console's proof pair
|
||||
d17328a55 sessions: Filbert row 187 r1 review, approve
|
||||
f81ebfbae sage: row 186 follow-ups filed as P19 405 and 406, P19 404 closed (mos-0955)
|
||||
9760d4967 sage: row 186 landing, P19 404 comment 354 and SESSIONS line, with Darkwing's row 186 SESSIONS line (09:13:21Z)
|
||||
16a936c79 queue: revs 2198-2200: row 186 Filbert approve comment 29419 (2198); landing note (269134d8d, patch-id c97b9f6e25cd, gate passed) and done (Sage 2199-2200)
|
||||
269134d8d row 186 (#1684): the activate plan's backup line says what the run will do
|
||||
3c5da3a91 sessions: Filbert row 186 r1 review, approve
|
||||
b367f5730 sage: rows 186-187 Vikunja comments (P19 404 351, P19 385 352-353) and SESSIONS line (mos-0954)
|
||||
5a768d82c queue: row 187 added and briefed (Part B A0, #1685), with Darkwing's row 186 start (rev 2193)
|
||||
6de3c86be plans: brief for row 187, activate carries the row 178 proof pair (mos-0928 Part B A0, #1685)
|
||||
1d7fd75e2 queue: row 186 added and briefed (P19 404, #1684)
|
||||
f5c72a5c3 plans: brief for row 186, the activate plan's backup line (P19 404, #1684)
|
||||
b724b7b0b sessions: Sage Part A window line, with Darkwing's 06:53:53Z and 08:28:01Z lines
|
||||
e24a13ad8 sage: Vikunja scripts and read-back records 2026-10-13 to 10-20 (decision 110, mos-0882)
|
||||
21aa1cab0 darkwing: re-pin window rows 177-185 packet, STATE.md (sha256 b586f11c9b6f1329), W.txt (edd4622c5c45ef3f), S1/S2/S2b/B snapshots, matrices, W10 browser check and P3 rehearsals (#1683; P19 386)
|
||||
9ddd8a92c plans: repin-rows-177-185 draft 4, R1 snapshot after the plain rollback (mos-0943)
|
||||
e7637b99c plans: repin-rows-177-185 draft 3, R1post expects one tester action.refused per refused read (mos-0935, mos-0942)
|
||||
2ea05700b plans: mos-0928 plan draft 2, NEW is the row 185 landing 490e2771d
|
||||
010666074 queue: revs 2185-2190: row 185 start and in-review on bf87dbba with request comment 29403 (Darkwing 2185-2187); Filbert approve comment 29404 (2188); landing note (490e2771d, patch-id 774cd23186d5, gate passed) and done (Sage 2189-2190)
|
||||
bus: scripts/bus-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --pm --claude /home/jwoltje/.local/bin/claude
|
||||
proof: off
|
||||
send: off
|
||||
Console: scripts/webui-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333
|
||||
CLI: scripts/stack-copy.sh wrapper 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --bin /home/jwoltje/.npm-global/bin
|
||||
@@ -1,21 +0,0 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -1,8 +0,0 @@
|
||||
stack-release: captured the rollback set: /home/jwoltje/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6
|
||||
stack-release: stopped both units
|
||||
stack-release: stopped store schema: 3i
|
||||
stack-release: backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6
|
||||
stack-release: installed 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: gate passed on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: activate ok: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 is live for mosaic-stack
|
||||
# exit 0
|
||||
@@ -1,2 +0,0 @@
|
||||
W0b 2026-10-20T22:00:49Z business file copy /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 (source /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93)
|
||||
W5 2026-10-20T22:02:13Z activation 20261020T220206Z-4429a104f6d6 backup /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 SHA256SUMS sha256 b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9
|
||||
@@ -1,7 +0,0 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
@@ -1,8 +0,0 @@
|
||||
# P6 2026-10-20T22:00:17Z
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6454 bytes
|
||||
@@ -1,4 +0,0 @@
|
||||
copy 490e2771d: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"bus.tester": true,
|
||||
"plugins": {
|
||||
"usage-source": [
|
||||
"transcript"
|
||||
],
|
||||
"channel": [
|
||||
"cli",
|
||||
"webui"
|
||||
],
|
||||
"harness": [
|
||||
"claude-code"
|
||||
]
|
||||
},
|
||||
"launch.by": "chief",
|
||||
"launch.instances has assistant": true,
|
||||
"roles with pi harness": [],
|
||||
"sha256": "1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
start 2026-10-20T21:46:09Z
|
||||
business-check.mjs: OK
|
||||
console-check.sh: OK
|
||||
1ba3c7efbc84a1d7eb8ee0068d32b7917f4e75cdc4066703944cea4c20db5b93 /mnt/storage/scratch/sage-pin-de352dfaf/fixture/console-stub.mjs
|
||||
a4500094e7c7a6dc21797eaf2d696f9814f272ece945c2735b79e4593f9c9503 /mnt/storage/scratch/sage-p2-4429/console-stub-tester.mjs
|
||||
== run 1: pinned stub, no tester listener
|
||||
stub pid 2495939 port 39561
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PORT 39561
|
||||
CALLS []
|
||||
run 1 console-check rc=0
|
||||
== run 2: main listener plus a tester listener on a second port
|
||||
stub pid 2496085 port 35213 tester port 35945
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PASS main refuses x-mosaic-tester: HTTP 403 "tester listener required"
|
||||
PASS tester /api/config: HTTP 200 "tester":true
|
||||
PASS tester /api/config send and edit off: HTTP 200 "send":false,"edit":false
|
||||
PASS tester refuses the main auth header: HTTP 403 "proxy header refused"
|
||||
PASS tester refuses project-file save: HTTP 403
|
||||
PORT 35213
|
||||
TPORT 35945
|
||||
CALLS []
|
||||
TESTER_PASS
|
||||
run 2 console-check rc=0 tester rc=0
|
||||
end 2026-10-20T21:46:10Z
|
||||
@@ -1 +0,0 @@
|
||||
2026-10-20T21:52:34Z
|
||||
@@ -1,10 +0,0 @@
|
||||
flags-check start 2026-10-20T21:53:52Z
|
||||
pid 2729699 alive: yes
|
||||
127.0.0.1:7341
|
||||
127.0.0.1:7342
|
||||
main /healthz: 200
|
||||
tester /healthz: 200
|
||||
main /api/config: {"board":"http://127.0.0.1:7331","send":false,"edit":false}
|
||||
pid 2729699 after TERM: gone
|
||||
listeners after: 0
|
||||
flags-check end 2026-10-20T21:53:56Z
|
||||
@@ -1,7 +0,0 @@
|
||||
Mosaic Console: http://127.0.0.1:7341/
|
||||
Board: http://127.0.0.1:7331
|
||||
Bus: mosaic-stack
|
||||
Preview: https://mosaic.woltje.com/ through the proxy, signed-in user from x-authentik-username, read-only
|
||||
Proxy: proxy proof off
|
||||
Tester Console: http://127.0.0.1:7342/ (binds the tester)
|
||||
Ctrl-C stops this server.
|
||||
@@ -1 +0,0 @@
|
||||
rc=0
|
||||
@@ -1,20 +0,0 @@
|
||||
PASS copy: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i
|
||||
PASS port: 127.0.0.1:7341 is free
|
||||
PASS backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T192956Z-490e2771d3d0 matches its SHA256SUMS
|
||||
PASS scratch-root: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data from the backup, config and business file under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/config, business.diff beside them
|
||||
PASS data-root-live: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3i
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PASS no-session: no sessions.json and no launches under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data
|
||||
PASS console-stopped: pid 2675437
|
||||
PASS host-stopped: pid 2675436
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6
|
||||
@@ -1 +0,0 @@
|
||||
2026-10-20T21:52:31Z
|
||||
@@ -1,7 +0,0 @@
|
||||
# P4 2026-10-20T22:00:17Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
rc 0
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# P4-P6 of agents/sage/work/window-plan/repin-4429a104f.md (f41729178), adapted from Darkwing's window-rows-177-185/p456.sh, read only.
|
||||
# P4 the tunnel unit, P5 the four REPO scripts against NEW, P6 the 7332 matrix on OLD (matrix-old.txt).
|
||||
# usage: p456.sh p4|p5|p6
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
FILES=(scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)
|
||||
# The plan's <32 letters>: a fixed dummy, not a secret. matrix-new.txt uses the same value.
|
||||
PROOF=abcdefghijklmnopqrstuvwxyzabcdef
|
||||
|
||||
p4() {
|
||||
echo "# P4 $(date -u +%FT%TZ)"
|
||||
local out; out="$(systemctl --user show mosaic-preview-tunnel.service -p ExecStart -p DropInPaths)"; local rc=$?
|
||||
printf '%s\n' "$out"; echo "# exit $rc"
|
||||
local specs; specs="$(grep -oE 'R:[^ ;]+' <<<"$out")"
|
||||
local n; n="$(grep -c . <<<"$specs")"
|
||||
echo "# R: specs ($n): $(tr '\n' ' ' <<<"$specs")"
|
||||
local drop; drop="$(sed -n 's/^DropInPaths=//p' <<<"$out")"
|
||||
if [[ $rc -eq 0 && $n -eq 1 && "$specs" == "R:0.0.0.0:7332:127.0.0.1:7332" && -z "$drop" ]]; then
|
||||
echo "P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty"
|
||||
else
|
||||
echo "P4 FAIL"; return 3
|
||||
fi
|
||||
}
|
||||
|
||||
p5() {
|
||||
echo "# P5 $(date -u +%FT%TZ); HEAD $(git -C $REPO rev-parse HEAD)"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- "${FILES[@]}"; local d=$?
|
||||
echo "# git diff --quiet $NEW HEAD -- ${FILES[*]}: exit $d"
|
||||
local st; st="$(git -C $REPO status --porcelain -- "${FILES[@]}")"; local s=$?
|
||||
echo "# git status --porcelain (exit $s): [${st}]"
|
||||
for f in "${FILES[@]}"; do echo "# $f HEAD blob $(git -C $REPO rev-parse HEAD:$f) NEW blob $(git -C $REPO rev-parse $NEW:$f)"; done
|
||||
if [[ $d -eq 0 && $s -eq 0 && -z "$st" ]]; then echo "P5 PASS"; else echo "P5 FAIL"; return 3; fi
|
||||
}
|
||||
|
||||
# one matrix line: label, then curl arguments; prints "<label> <code> <error or ok field>"
|
||||
req() {
|
||||
local label="$1"; shift
|
||||
local body; body="$(mktemp -p "${TMPDIR:-/tmp}")"
|
||||
local code; code="$(curl -s -m 10 -o "$body" -w '%{http_code}' "$@")"
|
||||
local field; field="$(node -e '
|
||||
let s = require("fs").readFileSync(process.argv[1], "utf8"), j;
|
||||
try { j = JSON.parse(s); } catch { console.log("non-json " + s.length + " bytes"); process.exit(0); }
|
||||
console.log(j.error !== undefined ? "error=" + JSON.stringify(j.error) : "ok=" + JSON.stringify(j.ok));
|
||||
' "$body")"
|
||||
rm -f "$body"
|
||||
echo "$label $code $field"
|
||||
}
|
||||
|
||||
p6() {
|
||||
req a http://127.0.0.1:7332/healthz
|
||||
req b -H 'Host: mosaic.woltje.com' http://127.0.0.1:7332/
|
||||
req c -H 'Host: mosaic.woltje.com' -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7332/
|
||||
req d -H 'X-authentik-username: tester-probe' http://127.0.0.1:7332/
|
||||
req e -H 'x-mosaic-tester: 1' http://127.0.0.1:7332/
|
||||
req f -H 'X-authentik-username: tester-probe' http://127.0.0.1:7333/
|
||||
req g -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7333/
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
p4) p4 ;;
|
||||
p5) p5 ;;
|
||||
p6) p6 ;;
|
||||
*) echo "usage: p456.sh p4|p5|p6" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,9 +0,0 @@
|
||||
# P5 2026-10-20T22:00:17Z; HEAD 4385b9ec183a2da4136ccd639d2a6b041eb9c68f
|
||||
# git diff --quiet 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh: exit 0
|
||||
# git status --porcelain (exit 0): []
|
||||
# scripts/stack-release.sh HEAD blob 3b068b1546d4020cffde5d73929d9130afe91911 NEW blob 3b068b1546d4020cffde5d73929d9130afe91911
|
||||
# scripts/webui-service.sh HEAD blob 0a32547c54ac233e75a21f4817ae230855dbc088 NEW blob 0a32547c54ac233e75a21f4817ae230855dbc088
|
||||
# scripts/bus-service.sh HEAD blob f71250204dfeceb51beeea5941ebbf3a33ce4b92 NEW blob f71250204dfeceb51beeea5941ebbf3a33ce4b92
|
||||
# scripts/stack-copy.sh HEAD blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f NEW blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f
|
||||
P5 PASS
|
||||
rc 0
|
||||
@@ -1,235 +0,0 @@
|
||||
// Merged window B+C, S1, S2 and D3 (repin-merged-bc.md "S1 and S2"): a read-only snapshot of every
|
||||
// table in a bus store, so the 3h to 3i migration and a restore can be checked without the bus.
|
||||
// OLD's broker doesn't take --tester on usage, names or threads.
|
||||
//
|
||||
// usage: node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]
|
||||
// --held the running bus holds the store: a plain readOnly open, which adds no file to a WAL
|
||||
// store that already has -wal and -shm (as stack-release.sh does for a held store).
|
||||
// Refuses store-not-held when either is absent
|
||||
// --stopped the bus is stopped: refuses store-unclean when -wal or -shm is present, else opens
|
||||
// with immutable=1 and readOnly, which creates no file and leaves the store's mtime
|
||||
// and sha256 alone
|
||||
// --after also counts each timed table's rows with `at` after STAMP (mos-0838 D3, before a
|
||||
// --restore). STAMP is a backup's STAMP file, in now_ms form (stack-release.sh:43,
|
||||
// 2026-10-20T00:11:14.123Z). The count is a text compare, as the tool's own heldcount
|
||||
// does, so every `at` in every timed table must have that form: a table with any
|
||||
// other value refuses at-format and names the table (sage-1125)
|
||||
// --backup also compares every table with a stack-release backup's bus.sqlite, which opens as
|
||||
// --stopped opens a store: immutable=1 and readOnly, refusing backup-unclean when -wal
|
||||
// or -shm is present, so nothing writes into the backup set
|
||||
//
|
||||
// It prints one JSON object: the schema version, then for every table in sqlite_master (type
|
||||
// table, less sqlite_*) the row count, the max seq and a sha256 of the rows (in seq order, else in
|
||||
// column order). `untimed` names the tables with no `at` column. For usage_records it adds per-run
|
||||
// input and output sums, counted as `mosaic usage` counts them (usage.mjs turnRecords). messages
|
||||
// and message_threads hold bodies and names, so they print counts only, no sha256 (sage-1125). It
|
||||
// prints counts, hashes and table and meta key names only: no message body, no instance, project
|
||||
// or thread name, no credential and no file content. It reads no config: the store path is always
|
||||
// given.
|
||||
//
|
||||
// With --backup, each table gets `vsBackup` and `stopTables` names every table other than events
|
||||
// whose rows differ from the backup's (sage-1123): a row with `at` after STAMP, a row past the
|
||||
// backup's max seq, a changed row at or below it, a changed untimed table, or a table only one
|
||||
// side has. meta counts only when a key other than schema_version and schema_digest changed (the
|
||||
// 3h to 3i migration sets those two). Events are left to the tool's own --events N.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, lstatSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// usage_records is hashed on these columns in this order, never SELECT * (mos-0888 F1), as
|
||||
// 3h-3i.sql:9 copies it. Every other table is hashed on its own columns in name order, so a
|
||||
// rebuild that reorders columns keeps the hash. usage_records with any other column set refuses.
|
||||
const USAGE_COLUMNS = ['seq', 'at', 'business', 'run', 'instance', 'provider', 'model', 'account', 'turn', 'source',
|
||||
'phase', 'status', 'code', 'cursor', 'input', 'output', 'cache_write', 'cache_read'];
|
||||
const COUNTS_ONLY = new Set(['messages', 'message_threads']);
|
||||
const SCHEMA_KEYS = ['schema_digest', 'schema_version'];
|
||||
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function refuse(code, detail) {
|
||||
process.stderr.write(`snapshot: ${code}: ${detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function args(argv) {
|
||||
const o = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--store' || a === '--after' || a === '--backup') {
|
||||
if (i + 1 >= argv.length) refuse('usage', `${a} needs a value`);
|
||||
o[a.slice(2)] = argv[++i];
|
||||
} else if (a === '--held' || a === '--stopped') {
|
||||
if (o.mode) refuse('usage', 'give one of --held and --stopped');
|
||||
o.mode = a.slice(2);
|
||||
} else refuse('usage', `unknown argument ${a}`);
|
||||
}
|
||||
if (!o.store || !o.mode) refuse('usage', 'node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]');
|
||||
if (!isAbsolute(o.store)) refuse('usage', '--store must be an absolute path');
|
||||
if (o.backup !== undefined && !isAbsolute(o.backup)) refuse('usage', '--backup must be an absolute path');
|
||||
if (o.after !== undefined && !(STAMP.test(o.after) && Number.isFinite(Date.parse(o.after))))
|
||||
refuse('usage', '--after must be a STAMP like 2026-10-20T00:11:14.123Z');
|
||||
return o;
|
||||
}
|
||||
|
||||
function file(path, code) {
|
||||
if (!existsSync(path) || !lstatSync(path).isFile()) refuse(code, `${path} is not a file`);
|
||||
}
|
||||
|
||||
function immutable(path, code) {
|
||||
for (const f of [path + '-wal', path + '-shm'])
|
||||
if (existsSync(f)) refuse(code, `${f} is present; an immutable read would skip the WAL`);
|
||||
return new DatabaseSync(pathToFileURL(path).href + '?immutable=1', { readOnly: true });
|
||||
}
|
||||
|
||||
function open({ store, mode }) {
|
||||
file(store, 'store-missing');
|
||||
if (mode === 'stopped') return immutable(store, 'store-unclean');
|
||||
// A plain readOnly open of a WAL store with no -wal and -shm creates them, so a store that
|
||||
// nothing holds refuses here instead of gaining two files.
|
||||
for (const f of [store + '-wal', store + '-shm'])
|
||||
if (!existsSync(f)) refuse('store-not-held', `${f} is absent; use --stopped for a store the bus doesn't hold`);
|
||||
return new DatabaseSync(store, { readOnly: true });
|
||||
}
|
||||
|
||||
const q = (name) => `"${name.replaceAll('"', '""')}"`;
|
||||
const value = (v) => (typeof v === 'bigint' ? String(v) : v instanceof Uint8Array ? { hex: Buffer.from(v).toString('hex') } : v);
|
||||
|
||||
// The same records `mosaic usage` counts: per (run, turn, source) the latest row, an end row
|
||||
// winning; a turn is known only when that row is an end row with status known.
|
||||
function byRun(rows) {
|
||||
const turns = new Map();
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify([row.business, row.run, row.turn, row.source]);
|
||||
if (row.phase === 'end' || !turns.has(key)) turns.set(key, row);
|
||||
}
|
||||
const runs = new Map();
|
||||
for (const row of turns.values()) {
|
||||
const key = JSON.stringify([row.business, row.run]);
|
||||
if (!runs.has(key)) runs.set(key, { business: row.business, run: row.run, input: 0n, output: 0n, unknown: new Set() });
|
||||
const r = runs.get(key);
|
||||
if (row.phase === 'end' && row.status === 'known') {
|
||||
r.input += BigInt(row.input);
|
||||
r.output += BigInt(row.output);
|
||||
} else r.unknown.add(row.turn);
|
||||
}
|
||||
return [...runs.values()].map(({ unknown, input, output, ...r }) => ({
|
||||
...r, input: String(input), output: String(output), unknownTurns: unknown.size,
|
||||
}));
|
||||
}
|
||||
|
||||
// meta is compared by key: the names of the keys whose value differs, never the values.
|
||||
function metaKeys(db) {
|
||||
return new Map(db.prepare('SELECT key, value FROM meta').all().map((r) => [String(r.key), String(r.value)]));
|
||||
}
|
||||
|
||||
// One table: rows, max seq and sha256; rows after `after`; with `priorMax` (the backup's max
|
||||
// seq) a second hash of the rows at or below it and the count of rows above it. The hashes stay
|
||||
// inside: a COUNTS_ONLY table's `t` gets none.
|
||||
function read(db, table, { after, priorMax } = {}) {
|
||||
const cols = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((c) => c.name);
|
||||
if (!cols.length) refuse('table-missing', table);
|
||||
let hashed = [...cols].sort();
|
||||
if (table === 'usage_records') {
|
||||
if (JSON.stringify(hashed) !== JSON.stringify([...USAGE_COLUMNS].sort()))
|
||||
refuse('columns-changed', `usage_records has columns ${hashed.join(',')}`);
|
||||
hashed = USAGE_COLUMNS;
|
||||
}
|
||||
const seq = cols.includes('seq');
|
||||
const at = cols.includes('at');
|
||||
const stmt = db.prepare(`SELECT ${hashed.map(q).join(',')} FROM ${q(table)} ORDER BY ${seq ? 'seq' : hashed.map(q).join(',')}`);
|
||||
stmt.setReadBigInts(true);
|
||||
const full = createHash('sha256');
|
||||
const prior = createHash('sha256');
|
||||
const limit = priorMax === undefined ? undefined : BigInt(priorMax ?? 0);
|
||||
let rows = 0;
|
||||
let rowsAfter = 0;
|
||||
let badAt = 0;
|
||||
let newRows = 0;
|
||||
const kept = [];
|
||||
for (const row of stmt.iterate()) {
|
||||
rows++;
|
||||
const line = JSON.stringify(hashed.map((c) => value(row[c]))) + '\n';
|
||||
full.update(line);
|
||||
if (seq && limit !== undefined) {
|
||||
if (row.seq <= limit) prior.update(line);
|
||||
else newRows++;
|
||||
}
|
||||
if (after !== undefined && at) {
|
||||
if (typeof row.at !== 'string' || !STAMP.test(row.at)) badAt++;
|
||||
else if (row.at > after) rowsAfter++;
|
||||
}
|
||||
if (table === 'usage_records') kept.push(row);
|
||||
}
|
||||
if (badAt) refuse('at-format', `${table} has ${badAt} row(s) whose at is not in the STAMP form, so a text compare with --after would miscount`);
|
||||
const sha256 = full.digest('hex');
|
||||
const t = { rows, maxSeq: seq ? (db.prepare(`SELECT max(seq) AS m FROM ${q(table)}`).get().m ?? null) : null };
|
||||
if (!COUNTS_ONLY.has(table)) t.sha256 = sha256;
|
||||
if (after !== undefined && at) t.rowsAfter = rowsAfter;
|
||||
if (table === 'usage_records') t.byRun = byRun(kept);
|
||||
return { t, sha256, cols: hashed, seq, at, newRows, priorSha: seq && limit !== undefined ? prior.digest('hex') : null };
|
||||
}
|
||||
|
||||
// Tables only: no index, view or trigger, and none of SQLite's own sqlite_* tables.
|
||||
const tablesOf = (db) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
|
||||
.all().map((r) => r.name);
|
||||
|
||||
const o = args(process.argv.slice(2));
|
||||
if (o.backup !== undefined) file(o.backup, 'backup-missing');
|
||||
const db = open(o);
|
||||
const bdb = o.backup === undefined ? null : immutable(o.backup, 'backup-unclean');
|
||||
const out = {};
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
out.schema = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
const names = tablesOf(db);
|
||||
const base = new Map();
|
||||
if (bdb) {
|
||||
out.backupSchema = bdb.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
for (const name of tablesOf(bdb)) base.set(name, read(bdb, name));
|
||||
}
|
||||
out.tables = {};
|
||||
out.untimed = [];
|
||||
const stop = [];
|
||||
for (const name of names) {
|
||||
const b = base.get(name);
|
||||
const r = read(db, name, { after: o.after, priorMax: b ? b.t.maxSeq : undefined });
|
||||
if (!r.at) out.untimed.push(name);
|
||||
if (bdb) {
|
||||
const v = { backupRows: b ? b.t.rows : null };
|
||||
let changed;
|
||||
if (!b) changed = true;
|
||||
else if (name === 'meta') {
|
||||
const now = metaKeys(db);
|
||||
const then = metaKeys(bdb);
|
||||
v.keysChanged = [...new Set([...now.keys(), ...then.keys()])].filter((k) => now.get(k) !== then.get(k)).sort();
|
||||
changed = v.keysChanged.some((k) => !SCHEMA_KEYS.includes(k));
|
||||
} else if (r.seq) {
|
||||
v.backupMaxSeq = b.t.maxSeq;
|
||||
v.newRows = r.newRows;
|
||||
v.priorSame = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.priorSha === b.sha256;
|
||||
changed = r.newRows > 0 || !v.priorSame;
|
||||
} else {
|
||||
v.same = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.sha256 === b.sha256;
|
||||
changed = !v.same;
|
||||
}
|
||||
if ((r.t.rowsAfter ?? 0) > 0) changed = true;
|
||||
r.t.vsBackup = v;
|
||||
if (changed && name !== 'events') stop.push(name);
|
||||
}
|
||||
out.tables[name] = r.t;
|
||||
}
|
||||
if (bdb) {
|
||||
out.onlyInBackup = [...base.keys()].filter((n) => !names.includes(n));
|
||||
out.stopTables = [...stop, ...out.onlyInBackup].sort();
|
||||
}
|
||||
if (o.after !== undefined) {
|
||||
out.after = o.after;
|
||||
out.eventsAfter = out.tables.events?.rowsAfter ?? null;
|
||||
}
|
||||
} finally {
|
||||
try { db.exec('ROLLBACK'); } catch {}
|
||||
db.close();
|
||||
bdb?.close();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f window step wrapper (copied from Darkwing's window-rows-177-185/w.sh): w.sh FILE TITLE CMD... runs CMD in REPO under a clean env (no ANTHROPIC_*, CLAUDE_*,
|
||||
# MOSAIC_* or pi variables), appends the title, command, output, exit code and date -u stamps to window/FILE,
|
||||
# prints the output, and exits with CMD's exit code.
|
||||
set -uo pipefail
|
||||
W=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
f="$W/$1"; title="$2"; shift 2
|
||||
E=(env -i HOME="$HOME" PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" LANG=C.UTF-8
|
||||
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}")
|
||||
t0=$(date -u +%FT%T.%3NZ)
|
||||
{ echo; echo "## $title"; echo "# start $t0"; echo "# command: (cd \$REPO && <clean env> $*)"; } >> "$f"
|
||||
out=$(cd /mnt/storage/src/mosaic-stack && "${E[@]}" "$@" 2>&1); rc=$?
|
||||
printf '%s\n' "$out" >> "$f"
|
||||
echo "# exit $rc, end $(date -u +%FT%T.%3NZ)" >> "$f"
|
||||
printf '%s\n# exit %s\n' "$out" "$rc"
|
||||
exit $rc
|
||||
@@ -1,9 +0,0 @@
|
||||
npm warn deprecated [email protected]: Use your platform's native DOMException instead
|
||||
|
||||
added 128 packages in 2s
|
||||
npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:
|
||||
npm warn install-scripts @google/[email protected] (preinstall: echo 'preinstall: no-op')
|
||||
npm warn install-scripts [email protected] (postinstall: node install.js)
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
@@ -1 +0,0 @@
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
@@ -1,16 +0,0 @@
|
||||
start 2026-10-20T21:45:57Z
|
||||
stack-copy rc=0
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
oauth4webapi 3.8.8
|
||||
IMPORT_OK
|
||||
LOCK_MATCH
|
||||
163M /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
/dev/nvme1n1p5 458G 405G 30G 94% /home
|
||||
copy 4429a104f: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
end 2026-10-20T21:45:59Z
|
||||
@@ -1,109 +0,0 @@
|
||||
// W10 of repin-4429a104f.md (e1272d1a), adapted from Darkwing's window-rows-177-185/w10-browser.mjs: the tester listener
|
||||
// on 127.0.0.1:7333 through the webui browser harness (packages/webui/tests/browser.mjs, same blob at HEAD and NEW). Reads only: before any page
|
||||
// script runs, fetch and XMLHttpRequest are wrapped so every call is logged and any method other than
|
||||
// GET or HEAD is refused in the page, and the script submits no form and clicks no button. The chat
|
||||
// check reads the #/chat list only and never opens the W9 thread page (the launcher reads lastOpen).
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
|
||||
const OUT = '/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f';
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const GUARD = `(() => {
|
||||
const log = window.__w10 = [];
|
||||
const ok = m => m === 'GET' || m === 'HEAD';
|
||||
const f = window.fetch;
|
||||
window.fetch = (input, init = {}) => {
|
||||
const method = String(init.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
log.push({ method, url, refused: !ok(method) });
|
||||
return ok(method) ? f(input, init) : Promise.reject(new TypeError('w10 refused ' + method));
|
||||
};
|
||||
const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__m = String(method).toUpperCase(); this.__u = String(url); return open.call(this, method, url, ...rest); };
|
||||
XMLHttpRequest.prototype.send = function (...a) { log.push({ method: this.__m, url: this.__u, xhr: true, refused: !ok(this.__m) }); if (!ok(this.__m)) throw new TypeError('w10 refused ' + this.__m); return send.apply(this, a); };
|
||||
})();`;
|
||||
|
||||
const b = await browser({ machine: false });
|
||||
const result = {};
|
||||
let failed = false;
|
||||
const check = (name, pass, detail) => { result[name] = { pass, ...(detail === undefined ? {} : { detail }) }; if (!pass) failed = true; };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
await b.viewport(1280, 1600);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
|
||||
await b.navigate(BASE + '#/settings');
|
||||
const ready = await wait('!!document.querySelector("#tester-banner") && !!document.querySelector("#routes-form") && !!document.querySelector(".tester-name-form")');
|
||||
check('settings loaded', ready);
|
||||
const s = await b.evaluate(`(() => {
|
||||
const q = x => document.querySelector(x);
|
||||
return {
|
||||
banner: q('#tester-banner')?.textContent.trim() ?? null,
|
||||
h1: q('#bus-view h1')?.textContent.trim() ?? null,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
convFormHidden: q('#conv-form')?.hidden ?? null,
|
||||
panels: [...document.querySelectorAll('#bus-view h2')].map(h => h.textContent.trim()),
|
||||
instancesTable: !!q('#instances-table'),
|
||||
nameForms: document.querySelectorAll('.tester-name-form').length,
|
||||
routesForm: !!q('#routes-form'),
|
||||
routesSubmitEnabled: q('#routes-form button[type=submit]')?.disabled === false,
|
||||
forbidden: [...document.querySelectorAll('#launch-settings-form, .assistant-form, #token-limits-table [data-limit-reset], #send-section')].map(e => e.id || e.className || e.tagName),
|
||||
tokenLimitsPanel: !!q('#token-limits-title') || !!q('#token-limits-table'),
|
||||
};
|
||||
})()`);
|
||||
result.settings = s;
|
||||
check('tester banner', /tester/i.test(s.banner ?? ''), s.banner);
|
||||
check('instance, routes and name editors present', s.instancesTable && s.routesForm && s.nameForms > 0);
|
||||
check('only tester forms (conv-form is index.html:21, global and hidden)', s.convFormHidden === true && s.forms.every(f => f === 'conv-form' || f === 'routes-form' || /tester-name-form/.test(f) || /^instance-(add|change)-form$/.test(f)), s.forms);
|
||||
check('no launch settings, assistant, limit reset or send', s.forbidden.length === 0, s.forbidden);
|
||||
check('no Token limits panel (plan W10 item 2, amended)', s.tokenLimitsPanel === false);
|
||||
await b.screenshot(`${OUT}/w10-settings.png`);
|
||||
|
||||
await b.navigate(BASE + '#/');
|
||||
// The Board is index.html:22 #board-view; bus.js hides #bus-view on it.
|
||||
const board = await wait('document.querySelector("#bus-view")?.hidden === true && !!document.querySelector("#board-view #waiting-title")');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const bd = await b.evaluate(`(() => { const v = document.querySelector('#board-view'); return {
|
||||
busViewHidden: document.querySelector('#bus-view')?.hidden ?? null,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
headings: [...v.querySelectorAll('h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim()),
|
||||
unavailable: /unavailable|refused|not authorized/i.test(v.textContent),
|
||||
length: v.textContent.length,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
}; })()`);
|
||||
result.board = bd;
|
||||
check('board reads', board && bd.busViewHidden === true && !bd.unavailable && bd.headings.length > 0, bd);
|
||||
await b.screenshot(`${OUT}/w10-board.png`);
|
||||
|
||||
await b.navigate(BASE + '#/chat');
|
||||
// chat.js threads(): ul.chat-thread-list with <a href="#/chat/threads/<id>">. Read only; no link is followed.
|
||||
const chatReady = await wait('!!document.querySelector("ul.chat-thread-list") || /No threads yet/.test(document.body.textContent)');
|
||||
const c = await b.evaluate(`(() => {
|
||||
const list = document.querySelector('ul.chat-thread-list');
|
||||
return {
|
||||
hash: location.hash,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
threads: list ? [...list.querySelectorAll('li')].map(li => ({ href: li.querySelector('a')?.getAttribute('href') ?? null, name: li.querySelector('a')?.textContent.trim() ?? null, instance: li.querySelector('.muted')?.textContent.trim() ?? null })) : [],
|
||||
conversationOpen: !!document.querySelector('.chat-conversation'),
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
};
|
||||
})()`);
|
||||
result.chat = c;
|
||||
check('chat lists the W9 thread', chatReady && c.threads.some(t => t.href === '#/chat/threads/' + W9_THREAD), c.threads);
|
||||
check('W9 thread page not opened', c.hash === '#/chat' && c.conversationOpen === false, c.hash);
|
||||
check('tester banner on chat', c.banner);
|
||||
await b.screenshot(`${OUT}/w10-chat.png`);
|
||||
|
||||
const log = await b.evaluate('window.__w10');
|
||||
result.requests = log;
|
||||
check('no write through the listener', log.every(r => !r.refused && (r.method === 'GET' || r.method === 'HEAD')), log.filter(r => r.refused));
|
||||
check('no request to the W9 thread messages or open', !log.some(r => r.url.includes(W9_THREAD)), log.filter(r => r.url.includes(W9_THREAD)));
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
result.error = String(e?.message ?? e);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.log(failed ? 'W10 browser: FAIL' : 'W10 browser: pass');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,30 +0,0 @@
|
||||
// W10 diagnosis after the two failed checks of w10-browser.mjs (sign-out form listed; chat list missing the W9 thread).
|
||||
// Read only, with the same GET/HEAD guard as w10-browser.mjs. Records each form's hidden flag on Settings, the /api/config
|
||||
// sso value the page sees, and every ul.chat-thread-list on #/chat (chat.js:93 renders one per project). No screenshots.
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const src = readFileSync(new URL('./w10-browser.mjs', import.meta.url), 'utf8');
|
||||
const GUARD = eval(src.slice(src.indexOf('const GUARD = ') + 14, src.indexOf('`;', src.indexOf('const GUARD = ')) + 1));
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const b = await browser({ machine: false });
|
||||
const out = {};
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
await b.navigate(BASE + '#/settings');
|
||||
await wait('!!document.querySelector("#routes-form")');
|
||||
out.config = await b.evaluate(`fetch('/api/config').then(r=>r.json()).then(j=>({sso:j.sso??null}))`);
|
||||
out.forms = await b.evaluate(`[...document.querySelectorAll('form')].map(f=>({id:f.id||f.className,hidden:f.hidden,action:f.getAttribute('action'),method:f.getAttribute('method')}))`);
|
||||
await b.navigate(BASE + '#/chat');
|
||||
await wait('document.querySelectorAll("ul.chat-thread-list").length >= 1');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
out.chat = await b.evaluate(`({hash:location.hash,open:!!document.querySelector('.chat-conversation'),projects:[...document.querySelectorAll('.chat-projects section.panel')].map(s=>({project:s.querySelector('h2')?.textContent.trim(),threads:[...s.querySelectorAll('ul.chat-thread-list li a')].map(a=>a.getAttribute('href'))}))})`);
|
||||
out.requests = await b.evaluate('window.__w10');
|
||||
} finally { await b.close(); }
|
||||
const signOut = out.forms.find(f => f.id === 'sign-out');
|
||||
const listed = out.chat.projects.some(p => p.threads.includes('#/chat/threads/' + W9_THREAD));
|
||||
const writes = out.requests.filter(r => r.refused || !['GET', 'HEAD'].includes(r.method));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
console.log(`sign-out hidden ${signOut?.hidden}, sso ${out.config.sso}; W9 thread listed ${listed}; thread page open ${out.chat.open}; writes ${writes.length}`);
|
||||
process.exit(signOut?.hidden === true && listed && !out.chat.open && writes.length === 0 ? 0 : 1);
|
||||
@@ -1,277 +0,0 @@
|
||||
# W9 go mos-1114, start 2026-10-20T22:05:51Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
# journal since W5: proxy proof off 1, Sign-in 0
|
||||
# Console pid 2950925 cwd /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# one running chief from this start: run r097436c91816
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
# project window-20261020-2205, thread 446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
# reply received 2026-10-20T22:06:05Z
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# thread read: 2 messages: tester->assistant REQUEST, assistant->tester RESULT
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
[]
|
||||
# exit 0
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread d0ba7a66, so no human thread exists to read
|
||||
# W9 checks and turns end 2026-10-20T22:06:06Z; the assistant run wait and S2 are w9b.sh
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 checks and the turns (repin-4429a104f.md at f41729178, sha256 e1272d1a; the W9 list of
|
||||
# repin-rows-177-185.md plus the journal Sign-in line and the Console cwd), on Mos's go mos-1114.
|
||||
# Adapted from Darkwing's window-rows-177-185/w9.sh. Every call goes through w.sh into W.txt; every bus call carries
|
||||
# --tester. A failed check stops the script. The wait for the assistant run and S2 are in w9b.sh, so the interim
|
||||
# report to Mos goes out between the two (report rule, waits over 5 minutes).
|
||||
# usage: w9.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
W5_STAMP="2026-10-20 22:02:06 UTC"
|
||||
W5_MS=$(date -d "2026-10-20T22:02:06Z" +%s)000
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
CC=/mnt/storage/scratch/sage-pin-de352dfaf/console-check.sh
|
||||
CC_SHA=d068fe02da897b700110a162d13d9671fe5994f053c45aff99113fade737094c
|
||||
CONSOLE_PID=2950925
|
||||
PRIOR_TID=d0ba7a66-aa3f-4918-9518-94132bce6b28
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
C_NEW=(node "$HOME/.local/share/mosaic-stack/copies/$NEW/packages/cli/src/cli.mjs")
|
||||
BZ=(--business mosaic-stack)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
|
||||
note "W9 go mos-1114, start $(date -u +%FT%TZ)"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
[ "$(sha $CC)" = "$CC_SHA" ] || stop "console-check.sh is not d068fe02 (SCRIPTS.sha256)"
|
||||
grep -q "/copies/$NEW/scripts/mosaic" "$MOSAIC" || stop "the wrapper is not pinned to NEW"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked"
|
||||
|
||||
# checks
|
||||
step .w9-status.txt "W9 status" scripts/stack-release.sh status mosaic-stack || stop "W9 status exit"
|
||||
grep -q "\"copy\":\"$NEW\"" $WIN/.w9-status.txt && grep -q '"pm":true' $WIN/.w9-status.txt && grep -q '"schema":"3i"' $WIN/.w9-status.txt || stop "W9 status: pointer not on NEW, pm true, 3i"
|
||||
grep -q 'unit flags: match the pointer' $WIN/.w9-status.txt || stop "W9 status: unit flags"
|
||||
step .w9-cc.txt "W9 console-check NEW ($CC, d068fe02)" bash $CC $NEW || stop "console-check exit"
|
||||
grep -qx CONSOLE_PASS $WIN/.w9-cc.txt || stop "console-check not CONSOLE_PASS"
|
||||
J=$(journalctl --user -u mosaic-webui.service --since "$W5_STAMP" --no-pager)
|
||||
N=$(printf '%s\n' "$J" | grep -c 'Proxy: proxy proof off'); S=$(printf '%s\n' "$J" | grep -c 'Sign-in:')
|
||||
{ echo; echo "## W9 webui journal since $W5_STAMP: 'Proxy: proxy proof off' and 'Sign-in:' counts"; echo "proxy proof off $N, Sign-in $S"; } >> "$W"
|
||||
note "journal since W5: proxy proof off $N, Sign-in $S"
|
||||
[ "$N" = 1 ] || stop "proxy proof off count is $N, not 1"
|
||||
[ "$S" = 0 ] || stop "Sign-in count is $S, not 0"
|
||||
systemctl --user show -p MainPID --value mosaic-webui.service | grep -qx "$CONSOLE_PID" || stop "Console MainPID is not $CONSOLE_PID"
|
||||
CWD=$(readlink /proc/$CONSOLE_PID/cwd)
|
||||
{ echo; echo "## W9 Console pid $CONSOLE_PID cwd"; ls -l /proc/$CONSOLE_PID/cwd | sed 's/^.* -> /-> /'; } >> "$W"
|
||||
note "Console pid $CONSOLE_PID cwd $CWD"
|
||||
[ "$CWD" = "$HOME/.local/share/mosaic-stack/copies/$NEW" ] || stop "Console cwd is not the NEW copy"
|
||||
step .w9-launches.txt "W9 launches list" $MOSAIC launches list || stop "launches list"
|
||||
step .w9-launches.json "W9 launches list --json" $MOSAIC launches list --json || stop "launches list --json"
|
||||
sed -i '$d' $WIN/.w9-launches.json
|
||||
CHIEF=$(node -e '
|
||||
const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),since=Number(process.argv[2]);
|
||||
const c=l.filter(x=>x.business==="mosaic-stack"&&x.instance==="chief"&&x.live===true);
|
||||
if(c.length!==1||!(Date.parse(c[0].startedAt)>=since)){console.error(`running chiefs ${c.length}`);process.exit(1)}
|
||||
process.stdout.write(c[0].run)' $WIN/.w9-launches.json "$W5_MS") || stop "launches: not one running chief from this start"
|
||||
note "one running chief from this start: run $CHIEF"
|
||||
step .w9-agents.txt "W9 agents --tester" $MOSAIC agents "${BZ[@]}" --tester || stop "agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w9-agents.txt && stop "the chief is working"
|
||||
|
||||
# turns
|
||||
step .w9-talk-chief.txt "W9 talk chief --tester" $MOSAIC talk chief "[mos test] re-pin $NEW: reply with one line, no staffing request" "${BZ[@]}" --tester || stop "talk chief"
|
||||
PROJ=window-$(date -u +%Y%m%d-%H%M)
|
||||
step .w9-project.txt "W9 project create $PROJ --tester" $MOSAIC project create $PROJ "${BZ[@]}" --tester || stop "project create"
|
||||
step .w9-thread.txt "W9 thread create $PROJ w9 --tester" $MOSAIC thread create $PROJ w9 "${BZ[@]}" --tester || stop "thread create"
|
||||
TID=$(sed -n "s/^created $PROJ\/w9 id \([0-9a-f-]*\) instance .*$/\1/p" $WIN/.w9-thread.txt)
|
||||
[ -n "$TID" ] || stop "no thread id"
|
||||
note "project $PROJ, thread $TID"
|
||||
echo "$TID" > $WIN/.w9-tid.txt
|
||||
step .w9-talk-thread.txt "W9 talk --thread --wait 180 --tester" $MOSAIC talk --thread "$TID" "[mos test] reply with one line, then stop" --wait 180 "${BZ[@]}" --tester || stop "talk --thread"
|
||||
note "reply received $(date -u +%FT%TZ)"
|
||||
date -u +%s > $WIN/.w9-reply-at.txt
|
||||
step .w9-thread-read.json "W9 thread read --json --tester" $MOSAIC thread read "$TID" --json "${BZ[@]}" --tester || stop "thread read"
|
||||
sed -i '$d' $WIN/.w9-thread-read.json
|
||||
node -e '
|
||||
const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
|
||||
console.log(`# thread read: ${t.length} messages: ${t.map(m=>`${m.from_role}->${m.to_role} ${m.class}`).join(", ")}`);
|
||||
process.exit(t.length===2&&t[0].from_role==="tester"&&t[1].from_role==="assistant"?0:1)' $WIN/.w9-thread-read.json | tee -a "$W" || stop "thread read is not the tester message plus one assistant reply"
|
||||
step .w9-threads.txt "W9 threads --tester" $MOSAIC threads "${BZ[@]}" --tester || stop "threads"
|
||||
grep -q "$TID\|$PROJ" $WIN/.w9-threads.txt || stop "threads does not list the W9 thread"
|
||||
step .w9-names.json "W9 C_NEW names --tester --json" "${C_NEW[@]}" names "${BZ[@]}" --tester --json || stop "names"
|
||||
step .w9-usage.json "W9 usage --tester --json" $MOSAIC usage "${BZ[@]}" --tester --json || stop "usage"
|
||||
if grep -q "${PRIOR_TID:0:8}" $WIN/.w9-threads.txt; then
|
||||
note "human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread ${PRIOR_TID:0:8}, so no human thread exists to read"
|
||||
else
|
||||
note "human-thread probe: S1's one thread is not in threads --tester; probe it by hand before S2"
|
||||
fi
|
||||
note "W9 checks and turns end $(date -u +%FT%TZ); the assistant run wait and S2 are w9b.sh"
|
||||
@@ -1,185 +0,0 @@
|
||||
# W9b start 2026-10-20T22:06:27Z: waiting for the launcher to end r497f1c817d4d (thread 446201de-e8b5-4df3-9356-d0d5422cc899), no stop by hand
|
||||
# r497f1c817d4d not live in launches list --json by 2026-10-20T22:21:07Z (5 s poll)
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
|
||||
## W9b launches log lines for r497f1c817d4d (at, event, instance, thread, run, reason, exitCode, signal only)
|
||||
at=2026-10-20T22:06:00.766Z event=launch instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.149Z event=thread-idle instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.171Z event=end instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d reason=stopped exitCode=0 signal=null
|
||||
|
||||
## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming r497f1c817d4d or thread-idle
|
||||
2026-10-20T17:21:05.171444-05:00 dragon-lin mosaic[2950923]: mosaic-bus: run r497f1c817d4d (assistant) ended: stopped
|
||||
(grep exit 0)
|
||||
# SNAP sha256 639fd28a checked before S2 2026-10-20T22:21:07Z
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# S2.json sha256 1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173
|
||||
# W9b end 2026-10-20T22:21:07Z
|
||||
rc=0
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 tail (adapted from Darkwing's window-rows-177-185/w9b.sh, under mos-0948's ruling for that window: the launcher
|
||||
# ends the assistant run at threadIdleSeconds 900 after the 22:06:05Z reply, about 22:21:05Z). Nothing is stopped by hand. Report if live at 22:27Z.
|
||||
# usage: w9b.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
RUN=r497f1c817d4d
|
||||
TID=446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
DEADLINE=$(date -d "2026-10-20T22:27:00Z" +%s)
|
||||
MARK18=$(date -d "2026-10-20T22:22:00Z" +%s)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
# launches log lines for the run: at, event, instance, thread, run, reason, exitCode, signal only
|
||||
runlog() { node -e '
|
||||
const keep=["at","event","instance","thread","run","reason","exitCode","signal"];
|
||||
for(const s of require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n")){const o=JSON.parse(s);if(o.run!==process.argv[2])continue;
|
||||
console.log(keep.filter(k=>k in o).map(k=>`${k}=${o[k]}`).join(" "))}' "$DATA/launches/mosaic-stack.jsonl" "$RUN"; }
|
||||
|
||||
note "W9b start $(date -u +%FT%TZ): waiting for the launcher to end $RUN (thread $TID), no stop by hand"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
END=; M18=
|
||||
while :; do
|
||||
$MOSAIC launches list --json > $WIN/.w9b-poll.json 2>&1 || stop "launches poll"
|
||||
LIVE=$(node -e 'const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(l.filter(x=>x.run===process.argv[2]&&x.live===true).map(x=>x.run).join(" "))' $WIN/.w9b-poll.json $RUN)
|
||||
[ -z "$LIVE" ] && { END=$(date -u +%FT%TZ); break; }
|
||||
NOW=$(date +%s)
|
||||
if [ -z "$M18" ] && [ $NOW -ge $MARK18 ]; then M18=1; note "22:22Z mark $(date -u +%FT%TZ): $RUN still live; launches log lines for it:"; runlog | tee -a "$W"; fi
|
||||
[ $NOW -ge $DEADLINE ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$END" ]; then
|
||||
note "STOP: $RUN still live at $(date -u +%FT%TZ) (22:27Z mark); a stop is Mos's call. S2 waits."
|
||||
runlog | tee -a "$W"
|
||||
exit 4
|
||||
fi
|
||||
note "$RUN not live in launches list --json by $END (5 s poll)"
|
||||
step .w9b-launches-after.txt "W9b launches list after the assistant run" $MOSAIC launches list
|
||||
{ echo; echo "## W9b launches log lines for $RUN (at, event, instance, thread, run, reason, exitCode, signal only)"; runlog; } | tee -a "$W"
|
||||
{ echo; echo "## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming $RUN or thread-idle"; journalctl --user -u [email protected] -u mosaic-webui.service --since "2026-10-20 22:05:00 UTC" --no-pager -o short-iso-precise | grep -E "$RUN|thread-idle|threadIdle"; echo "(grep exit ${PIPESTATUS[1]})"; } | tee -a "$W"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S2"
|
||||
note "SNAP sha256 639fd28a checked before S2 $(date -u +%FT%TZ)"
|
||||
step S2.json "S2 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held || stop "S2"
|
||||
sed -i '$d' $WIN/S2.json
|
||||
note "S2.json sha256 $(sha $WIN/S2.json)"
|
||||
note "W9b end $(date -u +%FT%TZ)"
|
||||
@@ -1,43 +0,0 @@
|
||||
# W0b-W4 start 2026-10-20T22:00:49Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked 2026-10-20T22:00:49Z
|
||||
# W0 copy present, w0.txt LOAD_OK
|
||||
# W0b: /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z, sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93, recorded in backups.txt
|
||||
rbb708846b605 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T19:29:59.045Z running
|
||||
rc36b17ef3788 mosaic-stack/pm claude-code claude-opus-5-5 by chief since 2026-10-20T20:20:56.252Z running
|
||||
# exit 0
|
||||
# W1 start stamp 2026-10-20T22:00:49Z
|
||||
# SNAP sha256 639fd28a checked before S1
|
||||
# S1.json sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"490e2771d3d057ab76991e2c7859cd251857e64c","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T192956Z-490e2771d3d0","at":"2026-10-20T19:30:00Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T01:41:05Z","op":"pm-on","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","backup":null,"activation":"20261020T014103Z-89af38a83afa","result":"ok","pm":"on"}
|
||||
{"at":"2026-10-20T07:58:00Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T075800Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 3229540, running, since 2026-10-20T19:29:57.662Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 3229657
|
||||
boot: organization file loaded at 2026-10-20T19:29:57.661Z
|
||||
unit flags: match the pointer
|
||||
drift: 109 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
# W3-plan.txt sha256 45de68d42b4cf88640fe0986d071dba4fa076b7db86ff4ed34de4bb6fa2915c6
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
DropInPaths=
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
# W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart
|
||||
# W4 bus Environment: 2 assignments; quote or backslash: none (values not recorded)
|
||||
# W0b-W4 end 2026-10-20T22:00:50Z
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W0b-W4 (repin-4429a104f.md at f41729178, sha256 e1272d1a...32b9; mos-1105, mos-1107 go). W0 ran before P2.
|
||||
# Adapted from Darkwing's window-rows-177-185/wsteps.sh.
|
||||
# Nothing is stopped. Each step goes through w.sh into W.txt; a failed check stops the script.
|
||||
# usage: wsteps.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
OLD=490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
BC=$REPO/agents/sage/work/window-plan/repin-de352dfaf/business-check.mjs
|
||||
BC_SHA=eece4d4671e1ce15ba81e1654c641925ac4988b343687b062897392285a8a1a7
|
||||
BIZ=$HOME/.config/mosaic-dev/businesses/mosaic-stack.json
|
||||
BIZ_SHA=1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
|
||||
note "W0b-W4 start $(date -u +%FT%TZ)"
|
||||
[ "$(git -C $REPO show f41729178:agents/sage/work/window-plan/repin-4429a104f.md | sha256sum | cut -c1-64)" = "$PLAN_SHA" ] || stop "plan at f41729178 is not e1272d1a"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file in REPO is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh || stop "P5: the four scripts differ from NEW"
|
||||
[ -z "$(git -C $REPO status --porcelain -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)" ] || stop "P5: the four scripts are dirty"
|
||||
[ "$(sha $BC)" = "$BC_SHA" ] || stop "business-check.mjs sha"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked $(date -u +%FT%TZ)"
|
||||
|
||||
# W0 ran at 21:45:57Z (w0.txt): the copy must still be there with LOAD_OK recorded.
|
||||
[ -f "$HOME/.local/share/mosaic-stack/copies/$NEW/scripts/mosaic" ] || stop "W0 copy missing"
|
||||
grep -qx LOAD_OK $WIN/w0.txt || stop "w0.txt has no LOAD_OK"
|
||||
note "W0 copy present, w0.txt LOAD_OK"
|
||||
|
||||
# W0b
|
||||
[ "$(sha $BIZ)" = "$BIZ_SHA" ] || stop "business file sha256 is not P1's 1e49bdf0"
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
COPY=$BIZ.pre-window-$STAMP
|
||||
[ ! -e "$COPY" ] || stop "$COPY exists"
|
||||
cp -p "$BIZ" "$COPY" || stop "W0b cp"
|
||||
[ "$(sha "$COPY")" = "$BIZ_SHA" ] || stop "W0b copy sha256"
|
||||
echo "W0b $(date -u +%FT%TZ) business file copy $COPY sha256 $(sha "$COPY") (source $BIZ sha256 $(sha "$BIZ"))" >> $WIN/backups.txt
|
||||
note "W0b: $COPY, sha256 $(sha "$COPY"), recorded in backups.txt"
|
||||
|
||||
# W1
|
||||
$WIN/w.sh W.txt "W1 agents --tester" $MOSAIC agents --business mosaic-stack --tester > $WIN/.w1-agents.txt || stop "W1 agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w1-agents.txt && stop "W1: the chief is working"
|
||||
$WIN/w.sh W.txt "W1 launches list" $MOSAIC launches list || stop "W1 launches list"
|
||||
note "W1 start stamp $(date -u +%FT%TZ)"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S1"
|
||||
note "SNAP sha256 639fd28a checked before S1"
|
||||
$WIN/w.sh W.txt "S1 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held > $WIN/S1.json || stop "S1"
|
||||
sed -i '$d' $WIN/S1.json # w.sh prints "# exit N" last; S1.json keeps the snapshot only
|
||||
note "S1.json sha256 $(sha $WIN/S1.json)"
|
||||
|
||||
# W2
|
||||
$WIN/w.sh W.txt "W2 status" scripts/stack-release.sh status mosaic-stack || stop "W2 status"
|
||||
|
||||
# W3
|
||||
$WIN/w.sh W.txt "W3 activate NEW --pm --plan" scripts/stack-release.sh activate $NEW mosaic-stack --pm --plan > $WIN/W3-plan.txt || stop "W3 plan exit"
|
||||
sed -i '$d' $WIN/W3-plan.txt
|
||||
note "W3-plan.txt sha256 $(sha $WIN/W3-plan.txt)"
|
||||
|
||||
# W4
|
||||
$WIN/w.sh W.txt "W4 bus unit (Environment omitted from the transcript)" systemctl --user show [email protected] -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 bus"
|
||||
$WIN/w.sh W.txt "W4 webui unit" systemctl --user show mosaic-webui.service -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 webui"
|
||||
BUSL=$(systemctl --user show [email protected] -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
WEBL=$(systemctl --user show mosaic-webui.service -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
for u in BUSL WEBL; do v=${!u}; grep -qx 'DropInPaths=' <<< "$v" || stop "W4 $u DropInPaths not empty"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$OLD/" || stop "W4 $u ExecStart does not name the OLD copy"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$NEW" && stop "W4 $u ExecStart names NEW"; grep '^ExecStart=' <<< "$v" | grep -q "[\"'\\\\]" && stop "W4 $u ExecStart has a quote or backslash"; done
|
||||
grep '^ExecStart=' <<< "$BUSL" | grep -q -- ' --pm' || stop "W4 bus ExecStart has no --pm"
|
||||
note "W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart"
|
||||
ENVL=$(systemctl --user show [email protected] -p Environment)
|
||||
note "W4 bus Environment: $(tr ' ' '\n' <<< "${ENVL#Environment=}" | grep -c =) assignments; quote or backslash: $(grep -q "[\"'\\\\]" <<< "$ENVL" && echo FOUND || echo none) (values not recorded)"
|
||||
note "W0b-W4 end $(date -u +%FT%TZ)"
|
||||
@@ -1365,4 +1365,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
|
||||
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
|
||||
2026-10-20T22:16:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 3 review, candidate df65a3d4c on darkwing/row190-r3 | APPROVE, Gitea 29713, queue rev 2504 (left for Sage's queue-commit); static review, no slot used
|
||||
|
||||
+11
-120
@@ -15,7 +15,7 @@ and changes nothing.
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
|
||||
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
|
||||
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
|
||||
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
|
||||
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
|
||||
@@ -270,9 +270,9 @@ scripts/mosaic follow --thread <thread> [--tester]
|
||||
scripts/mosaic stop <run>
|
||||
scripts/mosaic launches off | on [--business <id>]
|
||||
scripts/mosaic launches list [--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>
|
||||
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
|
||||
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
|
||||
scripts/bus-service.sh uninstall | status <business>
|
||||
```
|
||||
|
||||
The human commands read and resolve through the broker's human transport
|
||||
@@ -321,89 +321,12 @@ stdin and stdout it exits 4 with the usage.
|
||||
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||
|
||||
## Stack instances (`--instance NAME`, row 190)
|
||||
|
||||
Several stacks can run on one host, each with its own config, data root,
|
||||
units, Console port and CLI wrapper. The default instance is today's stack
|
||||
and keeps every name it had. A named instance is created once:
|
||||
|
||||
```
|
||||
scripts/bootstrap.sh --instance NAME
|
||||
```
|
||||
|
||||
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
|
||||
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
|
||||
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
|
||||
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
|
||||
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
|
||||
nothing when the root exists, is not empty and holds no marker naming NAME.
|
||||
A second run changes nothing. `~` is the home of the OS user that runs the
|
||||
instance.
|
||||
|
||||
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
|
||||
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
|
||||
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
|
||||
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
|
||||
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
|
||||
|
||||
| Given | Config | Names |
|
||||
|---|---|---|
|
||||
| neither | `~/.config/mosaic-dev/config.json` | the default's |
|
||||
| `MOSAIC_CONFIG` only | that file | the default's |
|
||||
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
|
||||
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
|
||||
|
||||
Every selection checks the data root's marker. A named instance whose root
|
||||
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
|
||||
names another instance refuses `instance-root-mismatch`, also when a config
|
||||
reaches it with no flag. Both exit 3 before anything is read or changed. A
|
||||
missing config exits 3 with the bootstrap command that creates it.
|
||||
|
||||
| | Default | Instance NAME |
|
||||
|---|---|---|
|
||||
| bus unit | `[email protected]` | `[email protected]` |
|
||||
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
|
||||
| Discord unit | `[email protected]` | `[email protected]` |
|
||||
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
|
||||
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
|
||||
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
|
||||
|
||||
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
|
||||
` [NAME]` after the Description, and the Console unit's comment on the
|
||||
variables it sets names `MOSAIC_CONFIG`. The default's renders are
|
||||
byte-identical to the templates' renders before row 190. `stack-copy.sh
|
||||
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
|
||||
carries NAME's marker, and refuses any other file. `check --instance NAME`
|
||||
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
|
||||
when the wrapper does not export NAME's config.
|
||||
|
||||
For every instance, a Console `--port` or `--tester-port` that another
|
||||
installed Console unit in the unit directory already takes refuses
|
||||
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
|
||||
`stack-release.sh activate` and `rollback` exit 3 before anything stops
|
||||
(`--plan` lists it). A unit with no `--port` holds 7332.
|
||||
|
||||
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
|
||||
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
|
||||
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
|
||||
rollback set holds NAME's two unit files, their own `.d` directories and
|
||||
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
|
||||
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
|
||||
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
|
||||
any named instance's config directory or `~/.mosaic-NAME`, even when that
|
||||
instance's config is unreadable.
|
||||
|
||||
All instances run under one OS user for now. That user's processes can
|
||||
read and control every instance; a separate user per instance is row 195.
|
||||
A real second instance on this host is a Q173 window, and tunnel or Traefik
|
||||
exposure of a named Console waits for Jason after Q175 SSO.
|
||||
|
||||
## Stack copies (`scripts/stack-copy.sh`)
|
||||
|
||||
```
|
||||
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
|
||||
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
|
||||
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
|
||||
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
|
||||
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
|
||||
```
|
||||
|
||||
One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
@@ -445,11 +368,11 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
common options: --copies DIR, --bin DIR, --instance NAME
|
||||
common options: --copies DIR, --bin DIR
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
```
|
||||
@@ -565,36 +488,6 @@ rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
`activate ... --sso` (row 200, #1698) starts the Console with Q175 sign-in
|
||||
(row 192). It drops `--public-host`, `--auth-header`, `--allow-user` and any
|
||||
proof pair from the pointer's Console flags and the unit, and puts `--sso` after
|
||||
the other flags, before `--send` and `--tester-port`. It may name the live copy
|
||||
once, so a copy already at row 192 or later turns sign-in on without a copy
|
||||
change. Later activates, `pm-on` and rollbacks to a copy with `--sso` carry it.
|
||||
`--sso` with a proof pair or `--no-proof`, or on any other verb, is a usage
|
||||
error. Before anything stops, a run whose Console flags hold `--sso` refuses
|
||||
`sso-target` for a target Console without it, `sso-config` when the target's own
|
||||
`checkConsole` refuses the config's `console` key (or there is none, in which
|
||||
case, from another copy, it names the first step: activate the target without
|
||||
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
|
||||
that is missing, a symlink, not a regular file, not owned by this uid, has group
|
||||
or other bits, resolves inside the repository or the copies, or is empty. It
|
||||
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
|
||||
prints one `sso:` line: `on` with what it drops and carries, `carried`, or
|
||||
`dropped` on a rollback.
|
||||
|
||||
A rollback from an `--sso` pointer to a copy whose Console lacks `--sso` takes
|
||||
the Console flags from the rollback set of the activation that moved the stack
|
||||
onto the pointer's copy from the target, past any same-copy re-pin, checked
|
||||
against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
|
||||
When that set is missing, fails its sums, holds no readable argv or already ran
|
||||
`--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
|
||||
refuses `config-console-key` before anything stops when the config holds a
|
||||
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy
|
||||
before row 192 refuses the key). The message says to remove the key first. So a
|
||||
window re-pins to row 192 or later before it writes the key, and a rollback past
|
||||
row 192 removes the key first.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
@@ -709,7 +602,6 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
```bash
|
||||
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
|
||||
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
|
||||
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
|
||||
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
|
||||
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
|
||||
@@ -787,9 +679,8 @@ Each value is held to a plain character set the unit can carry (no space,
|
||||
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
|
||||
with a letter or digit; anything else is refused. With `--auth-header
|
||||
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
|
||||
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
|
||||
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
|
||||
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
|
||||
uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
|
||||
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
|
||||
port already in use) is not restarted.
|
||||
|
||||
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
|
||||
@@ -814,7 +705,7 @@ scripts/discord.sh stop <binding>
|
||||
scripts/discord.sh unlock <binding>
|
||||
scripts/discord.sh recover <binding>
|
||||
scripts/discord.sh reload <binding>
|
||||
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
|
||||
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
|
||||
```
|
||||
|
||||
One seat's conversation reachable from listed channels of one Discord
|
||||
|
||||
@@ -98,55 +98,6 @@ every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-01b survey escapes, list item lines and attachment embeds
|
||||
|
||||
### Problem
|
||||
|
||||
Webby's row 212 review (webby-0041, `agents/webby/work/queue-212/REVIEW-r1.md`, approve on bdf18272a)
|
||||
left three notes that change C2-01's code. Row 212 lands as approved, and these come after it.
|
||||
- N1. The comment at `packages/library/src/survey.mjs:76` is false. `JSON.stringify` leaves C1 and bidi
|
||||
controls raw, so a folder name can carry them into the survey output.
|
||||
- N2. A front matter link reports its key's line, not its list item's (`reader.mjs:156`, `:162-165`). Two
|
||||
links under `relationships:` both point at the key, and so do their R4, R5 and R6 problems.
|
||||
- N3. `links.mjs:2` reads `![[x]]` embeds as links, so an embedded image that is unmapped gives R4 "a link
|
||||
to no file". Design 1.3 says nothing about embeds.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn.
|
||||
- Reviewer: Webby.
|
||||
- After: C2-01.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/library/src/survey.mjs`, `src/reader.mjs`, `src/links.mjs`, `packages/library/README.md` where
|
||||
it describes these, and `packages/library/tests/`.
|
||||
|
||||
### What ships
|
||||
|
||||
- N1. The survey prints U+0080 to U+009F, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069 in any
|
||||
name as `\uXXXX` escapes, and the comment says what the code does.
|
||||
- N2. A link in a front matter block list reports its list item's line. A link in a scalar or flow value
|
||||
keeps the key's line.
|
||||
- N3 (sage-1517, Gosalyn's proposal). An embed whose target has an extension other than `.md` is an
|
||||
attachment. It is neither resolved nor indexed, and it never gives R4. The survey still counts it as an
|
||||
embed. An embed with no extension or with `.md` resolves as a link, so an unresolved one is R4.
|
||||
|
||||
Tests: one fixture folder name per escaped range prints escaped. Two links under one block list key report
|
||||
two different lines, and their problems carry those lines. `![[cover.png]]` gives no problem and counts as
|
||||
an embed, `![[missing-note]]` gives R4, and `![[note.md]]` resolves.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The index and queries (C2-05a), and webby-0041 N4 and N5, which go to C2-12 and C2-10b.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The library tests are green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file
|
||||
|
||||
### Problem
|
||||
@@ -431,8 +382,6 @@ The setup sheet in plain words:
|
||||
- That a short link to a name that also exists in another of her repos can point somewhere else for the
|
||||
agents, and that a full-path link avoids this.
|
||||
- What changes for Bean and Chet: short cited items, no passages (design 4).
|
||||
- That a list of links in a note's properties uses the quoted form Obsidian's property editor writes,
|
||||
`appears_in: ["[[A]]", "[[B]]"]`. The unquoted `[[[A]], [[B]]]` is R3 (webby-0041 N4).
|
||||
|
||||
The survey run sheet gives the exact `library survey` command with C2-01's real flags, and states what
|
||||
the output holds: names and counts only.
|
||||
@@ -741,11 +690,9 @@ Design 7.4. The chat view renders a cited reply through `renderItems`: each item
|
||||
with a scope badge (canon, playground or web) and "changed since" when the file's last commit is newer
|
||||
than the cited one. Each notice shows its fixed line. A status line reads `library.status`: the served
|
||||
commit and its age, `library-diverged`, a fetch error, and the count of R-problems with a link to the
|
||||
list. A web cite's `url` renders as a link only when its scheme is `https`, and as plain text otherwise.
|
||||
`renderItems` doesn't check the scheme (`packages/library/src/render.mjs:14-16`, webby-0041 N5).
|
||||
list.
|
||||
|
||||
Tests: webui tests for items, scope badges, notices, "changed since" and the status line, and a web cite
|
||||
with an `http:` and a `javascript:` url that each render as text.
|
||||
Tests: webui tests for items, scope badges, notices, "changed since" and the status line.
|
||||
|
||||
### Out of scope
|
||||
|
||||
@@ -789,12 +736,8 @@ precheck prints only variable names, never values (webby-0034 N2, webby-0035).
|
||||
`/srv/mosaic`, never from the release, because `mosaic` can write the release.
|
||||
|
||||
Inputs. `--profile factory|library` (default `library`), `--user`, `--home`, `--business`, `--copy ID`,
|
||||
`--node PATH`, with `factory` only `--pm` and `--claude PATH`, and the Console's serve flags after `--`.
|
||||
Row 195 calls it with those names. `library` refuses `--pm` and `--claude`.
|
||||
`--node PATH`, and the Console's serve flags after `--`. Row 195 calls it with those names.
|
||||
- `--node` must be an absolute path to a root-owned regular file outside `<home>`.
|
||||
- `--claude` follows the `--node` rule: an absolute path to a root-owned regular file outside `<home>`, so
|
||||
nothing running as `<user>` can replace the binary the bus runs (sage-1517, darkwing on gosalyn-0057).
|
||||
Row 195 installs claude under a root-owned prefix to match.
|
||||
- The copy id must be exactly 40 lowercase hex characters naming a directory that holds
|
||||
`scripts/mosaic`, directly under `<home>/.local/share/mosaic-stack/copies`. That is the rule
|
||||
`copy_root` uses (`scripts/stack-copy.sh:6`, `:20`). Anything else refuses before the render.
|
||||
@@ -817,11 +760,9 @@ Row 195 calls it with those names. `library` refuses `--pm` and `--claude`.
|
||||
- It refuses `--checkout`. Her host has no canonical checkout, and the queue store refuses a read
|
||||
from the copy, so the queue panel isn't part of Case 2 (webby-0036 N4).
|
||||
- It refuses `--board`. Case 2 runs no control board, and design 9.3 says why the default stays.
|
||||
The factory profile (gosalyn-0057 items 1-3, sage-1517) takes `--port N` in 1..1023 and `--business ID`,
|
||||
both required, and an optional `--tester-port N` in 1024..65535, never equal to `--port`, because
|
||||
Case 1's acceptance uses the tester. .53 has no proxy, so `factory` refuses `--public-host`,
|
||||
`--auth-header`, `--allow-user`, `--proof-header`, `--proof-file` and `--send`. It refuses `--checkout`
|
||||
and `--board` as `library` does, and any other flag.
|
||||
The factory profile takes the same set plus an optional `--tester-port N` in 1024..65535, never equal
|
||||
to `--port`, because Case 1's acceptance uses the tester. Row 195 names any other difference in its
|
||||
own brief.
|
||||
|
||||
The render. It renders two system units from the checkout's existing templates and changes neither:
|
||||
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, enabled as
|
||||
@@ -836,13 +777,7 @@ The render. It renders two system units from the checkout's existing templates a
|
||||
node's directory is already in it, so with `--node /usr/bin/node` a node in `/usr/local/bin` would
|
||||
come first and the bus would run it.
|
||||
- `@FLAGS@` is the checked serve flags, in the order given.
|
||||
- `@PM_FLAGS@` is ` --pm` when `factory` is given `--pm`, as `scripts/bus-service.sh:69-70` renders it,
|
||||
and empty otherwise.
|
||||
- `@CLAUDE_ENV@` is the line `Environment=MOSAIC_CLAUDE_BIN=<--claude>` when `factory` is given
|
||||
`--claude`, and empty otherwise.
|
||||
- Row 190 (round 3, in review) adds `@CONFIG_ENV@` and `@SESSION_ENV@` to the templates. They aren't on
|
||||
refactor, and the render refuses an unknown token, so whichever of rows 190 and 230 lands second adds
|
||||
both to this script with tests. A comment at the script's token list says so (gosalyn-0057 item 4).
|
||||
- `@PM_FLAGS@` and `@CLAUDE_ENV@` render empty.
|
||||
- `%h` becomes the literal `--home`, because in a system unit `%h` is root's home.
|
||||
- After the render it refuses any `@[A-Z_]+@` or `%h` left in either unit.
|
||||
|
||||
@@ -882,8 +817,7 @@ none, and it refuses one. It also refuses:
|
||||
(`scripts/webui-service.sh:266-273`).
|
||||
- on a re-install, an installed unit that differs from the render in anything but the copy id. So the
|
||||
node path and serve flags must equal the installed ones. The copy id is the one value that may
|
||||
change, so a re-pin or a rollback to another copy re-renders both units together (row 195). With
|
||||
`factory`, `--pm` and `--claude` must also match the installed bus unit.
|
||||
change, so a re-pin or a rollback to another copy re-renders both units together (row 195).
|
||||
|
||||
It writes both units under `/etc/systemd/system/` as `root:root` 0644. It never uses `systemctl link`,
|
||||
and it refuses a symlink at either unit path. It opens no file under `<home>`. It only stats the copy
|
||||
@@ -915,9 +849,7 @@ checks below. C2-13b adds the library checks to the same file. It refuses unless
|
||||
`mosaic` process bind the port, a session included, and makes the Console's capability pointless
|
||||
(design 9.3, mos-1100).
|
||||
- When some instance's harness is claude-code, one marker prompt run through the host's claude with
|
||||
`--system-prompt-file` shows that it replaces the default prompt. The claude version is printed. The
|
||||
host's claude is the installed bus unit's `MOSAIC_CLAUDE_BIN` when the unit sets one, as `factory`
|
||||
with `--claude` does, and `claude` on PATH otherwise. With
|
||||
`--system-prompt-file` shows that it replaces the default prompt. The claude version is printed. With
|
||||
the design's model path, Bean and Chet run pi, so this check is skipped unless she adds a claude-code
|
||||
specialist.
|
||||
- When an instance's provider is `zai`, `<configDir>/secrets/claude-session.env` exists, is a regular
|
||||
@@ -939,9 +871,6 @@ It also prints free memory and verifies that HOME is `<home>`.
|
||||
- both units hold the four hardening lines once, in order, and only the Console holds the two
|
||||
capability lines
|
||||
- a check of the script for `systemctl link` finds nothing
|
||||
- with `factory`, `--pm` renders an `ExecStart=` ending in `bus start <business> --pm` with one `--pm`,
|
||||
and `--claude` renders exactly one `Environment=MOSAIC_CLAUDE_BIN=` line with that path. Without them
|
||||
both tokens render empty
|
||||
|
||||
These cases refuse:
|
||||
- an installed unit edited to `User=root`
|
||||
@@ -959,17 +888,13 @@ These cases refuse:
|
||||
- a copy id that is short, uppercase, holds `/` or `..`, or names no directory
|
||||
- with `library`: `--port 7332`, a missing proof pair, a proof file owned by root, at 0644, a symlink, or
|
||||
under the data root, `--tester-port`, `--checkout`, `--board`, a `--business` different from the
|
||||
unit's, `--pm`, `--claude`, and an unknown flag. With `factory`: `--tester-port` equal to `--port`, a
|
||||
missing `--port` or `--business`, each of `--public-host`, `--auth-header`, `--allow-user`,
|
||||
`--proof-header`, `--proof-file` and `--send`, a `--claude` that is relative, not root-owned or under
|
||||
`<home>`, and a re-install with a different `--pm` or `--claude`.
|
||||
unit's, and an unknown flag. With `factory`: `--tester-port` equal to `--port`.
|
||||
|
||||
The precheck runs against a scratch data root. Each refusal has a case. A fake `node` printing v22
|
||||
refuses, a fake `bwrap` that exits 1 refuses, and an unprivileged port start at or below the Console's
|
||||
port refuses. The claude check runs against a fake claude that appends rather than replaces, and that
|
||||
case refuses. With no claude-code instance the claude check doesn't run, and a test shows a missing
|
||||
claude passes. When the bus unit sets `MOSAIC_CLAUDE_BIN`, the check runs that binary: a replacing fake
|
||||
there passes while an appending fake sits first on PATH, and the swap refuses. The key file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and
|
||||
claude passes. The key file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and
|
||||
with `ZAI_API_KEY=x` followed by `ZAI_API_KEY=`. A search of the test output for the fixture key value
|
||||
finds nothing. `factory` passes with the tester on and refuses a business file with a library project.
|
||||
`library` refuses with the tester on.
|
||||
|
||||
+494
-45
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
||||
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 } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, unhireableAction } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -29,6 +29,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',
|
||||
@@ -64,6 +67,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)));
|
||||
@@ -97,10 +103,42 @@ 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']);
|
||||
// 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 (#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 } : 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',
|
||||
@@ -145,6 +183,7 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
|
||||
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'];
|
||||
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.
|
||||
@@ -180,6 +219,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;
|
||||
@@ -199,11 +268,13 @@ export class Broker {
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
#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;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -218,6 +289,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 [
|
||||
@@ -240,34 +320,17 @@ 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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
#now() {
|
||||
this.#clock = Math.max(Date.now(), this.#clock + 1);
|
||||
@@ -304,6 +367,92 @@ 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 and retired checks.
|
||||
#launchable(s, instance, thread) {
|
||||
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';
|
||||
}
|
||||
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);
|
||||
@@ -376,6 +525,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));
|
||||
@@ -772,6 +927,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');
|
||||
@@ -1069,6 +1225,70 @@ 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');
|
||||
}
|
||||
return { transaction: out.transaction, retry: false };
|
||||
}
|
||||
// The close of a taken request, with the run it started, the delete's transaction or the code
|
||||
// that stopped it.
|
||||
lifecycleDone(record) {
|
||||
keys(record, ['business', 'request', 'run', 'transaction', 'refusal'], ['business', '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');
|
||||
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, ...body } = record;
|
||||
this.#lifecycleStep(business, 'lifecycle.done', body);
|
||||
return lifecycleView(this.#lifecycle(business, r.request));
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
@@ -1245,7 +1465,9 @@ export class Broker {
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: 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.
|
||||
@@ -1262,13 +1484,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,
|
||||
@@ -1283,20 +1534,14 @@ 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. 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);
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -1316,6 +1561,153 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// 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');
|
||||
// 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") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
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
|
||||
@@ -1610,6 +2002,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 });
|
||||
@@ -2285,6 +2680,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. Its run reaches the registry, and the lifecycle.end
|
||||
// lets the pass signal it there.
|
||||
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);
|
||||
|
||||
@@ -20,6 +20,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);
|
||||
@@ -44,6 +48,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).
|
||||
|
||||
@@ -11,6 +11,11 @@ import { readSessions } from '../../seat/src/session.mjs';
|
||||
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 { 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 { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
@@ -103,6 +108,40 @@ export async function startBroker({
|
||||
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
|
||||
for (const id of Object.keys(businesses)) await limits.refresh(id);
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// 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. Create and hire add neither,
|
||||
// because credentialed definitions are refused.
|
||||
// A role not in the boot copy matches only with no references and no bot.
|
||||
const fixedRole = r => canonicalJson({ credentials: r?.credentials ?? {}, 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 ?? {}).map(p => p.root).sort()),
|
||||
});
|
||||
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
|
||||
&& 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;
|
||||
}
|
||||
}
|
||||
const broker = new Broker({
|
||||
usage,
|
||||
limits,
|
||||
@@ -118,19 +157,32 @@ 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);
|
||||
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;
|
||||
return verb==='instances.set'?{...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 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
@@ -143,6 +195,15 @@ 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
@@ -241,6 +302,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,
|
||||
|
||||
@@ -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,403 @@ 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.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.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);
|
||||
});
|
||||
|
||||
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.error = new Error('disk');
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
|
||||
writer.error = 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 = () => 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);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -94,3 +96,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,373 @@
|
||||
// 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 } = {}) {
|
||||
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 });
|
||||
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'), []);
|
||||
});
|
||||
|
||||
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,126 @@
|
||||
// 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';
|
||||
|
||||
// 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) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { harness: ['pi', 'claude-code'] };
|
||||
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);
|
||||
f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } });
|
||||
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']);
|
||||
f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' });
|
||||
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 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 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' }));
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -156,7 +156,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.
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// The access file, <configDir>/access.json (federation design section 3.5,
|
||||
// FED R1, #414). It names the extra users who may sign in to this instance
|
||||
// and the grants that let them reach a business's threads. The validator is
|
||||
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
||||
// as arguments. Any failed rule refuses the whole file, which leaves every
|
||||
// user with no grant (fail closed).
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
|
||||
export function accessFilePath(dir = configDir()) {
|
||||
return join(dir, "access.json");
|
||||
}
|
||||
|
||||
function requireArray(value, where) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value, where) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectKey(provider, sub) {
|
||||
return JSON.stringify([provider, sub]);
|
||||
}
|
||||
|
||||
function checkContext(context) {
|
||||
const where = "access context";
|
||||
requireObject(context, where);
|
||||
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
||||
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
||||
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
||||
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
||||
requireObject(context.businesses, `${where}.businesses`);
|
||||
const businesses = new Map();
|
||||
for (const [id, business] of Object.entries(context.businesses)) {
|
||||
const at = `${where}.businesses.${id}`;
|
||||
requireId(id, `${where}.businesses key`);
|
||||
requireObject(business, at);
|
||||
requireId(business.human, `${at}.human`);
|
||||
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
||||
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
||||
}
|
||||
const owners = new Set();
|
||||
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
||||
const at = `${where}.ssoSubjects[${i}]`;
|
||||
requireObject(s, at);
|
||||
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
||||
});
|
||||
return { providers, peers, businesses, owners };
|
||||
}
|
||||
|
||||
function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
subjects.forEach((s, i) => {
|
||||
const sat = `${at}.subjects[${i}]`;
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
||||
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
||||
seenSubjects.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
||||
function checkScope(value, where, check) {
|
||||
const list = requireArray(value, where);
|
||||
if (list.length === 0) refuse(`${where} must not be empty`);
|
||||
if (list.includes(ALL)) {
|
||||
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
// Check a parsed access document against the instance's context. Returns the
|
||||
// document frozen. Every refusal is a BusinessError naming the key path. An
|
||||
// expired grant is not refused: covers() treats it as absent.
|
||||
export function checkAccess(document, context) {
|
||||
const ctx = checkContext(context);
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
|
||||
// Read and check the access file. One descriptor, opened without following a
|
||||
// link, serves the stat checks and the read (readJsonFile). The file holds
|
||||
// authority, so it must belong to this uid and not be writable by group or
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -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 {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -18,7 +19,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"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "retired"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -118,7 +119,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];
|
||||
@@ -142,7 +143,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
|
||||
@@ -205,9 +228,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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
|
||||
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";
|
||||
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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";
|
||||
@@ -10,11 +11,59 @@ import { checkRoutes } 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 { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
|
||||
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).
|
||||
export function deleteRefusal(document, rolesDir, instance) {
|
||||
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
|
||||
const entry = document.roles[instance];
|
||||
if (END_PROTECTED.includes(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` };
|
||||
}
|
||||
const definition = loadRole(rolesDir, entry.definition);
|
||||
if (definition.credentials.length > 0 || entry.tracker !== undefined || entry.credentials !== undefined) {
|
||||
return { code: "organization-credentials-out-of-scope", message: `${instance} holds credentials or a tracker bot; 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,
|
||||
@@ -64,14 +113,19 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
launched: launch ? launch.instances.includes(name) : false,
|
||||
launcher: launch ? launch.by === name : false,
|
||||
protected: END_PROTECTED.includes(entry.definition),
|
||||
kept: (launch?.keep ?? []).includes(name),
|
||||
}]));
|
||||
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");
|
||||
@@ -80,7 +134,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") {
|
||||
@@ -91,15 +145,18 @@ 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");
|
||||
return commit(business, old, planDelete(old, instance), { revision: args.revision, actor });
|
||||
}
|
||||
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");
|
||||
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
|
||||
definitionFor(rolesDir, args.definition);
|
||||
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
redefined = true;
|
||||
} else {
|
||||
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
|
||||
if (!exists) throw new OrganizationError("organization-unknown-instance");
|
||||
@@ -115,8 +172,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")) {
|
||||
@@ -125,6 +181,95 @@ 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");
|
||||
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);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name } = args;
|
||||
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)) return { ...read(business), transaction: null, delisted: false };
|
||||
if (old.launch.instances.length === 1) {
|
||||
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
||||
return { ...commit(business, old, next, { revision, actor, context }), delisted: true };
|
||||
}
|
||||
|
||||
// The checks every write runs, then the write.
|
||||
function commit(business, old, next, { revision, actor, context }) {
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
||||
@@ -151,9 +296,9 @@ 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 };
|
||||
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, veto, hire, fire };
|
||||
}
|
||||
|
||||
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
function write(id, document, { revision, actor }) {
|
||||
// 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, context }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
@@ -200,7 +202,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), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// 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, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, unhireableAction,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -17,7 +17,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) {
|
||||
@@ -75,6 +75,25 @@ 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.
|
||||
function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
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;
|
||||
@@ -134,8 +153,9 @@ function checkContract(contract, file) {
|
||||
// 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");
|
||||
@@ -147,7 +167,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) {
|
||||
@@ -165,6 +185,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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,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",
|
||||
@@ -54,7 +58,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",
|
||||
@@ -78,15 +82,42 @@ 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,
|
||||
// cited spec or not. A role file may not list these under withinRole or
|
||||
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
|
||||
// account.create, credential.mint, message.external, git.push.protected and
|
||||
// account.create, credential.mint, message.external, git.push.protected,
|
||||
// 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"]);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import * as access from "../src/access.mjs";
|
||||
|
||||
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
|
||||
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
|
||||
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
|
||||
// interface edits one place.
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
|
||||
ssoSubjects: [],
|
||||
};
|
||||
const check = (doc) => access.checkAccess(doc, CONTEXT);
|
||||
|
||||
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
|
||||
const NOW = new Date("2026-10-20T19:30:00Z");
|
||||
|
||||
function file(level) {
|
||||
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
|
||||
if (level !== undefined) grant.level = level;
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
|
||||
grants: [grant],
|
||||
};
|
||||
}
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a grant's level is collaborator or viewer", () => {
|
||||
for (const level of ["collaborator", "viewer"]) {
|
||||
assert.doesNotThrow(() => check(file(level)), level);
|
||||
}
|
||||
});
|
||||
|
||||
test("any other level refuses with the key path named", () => {
|
||||
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
|
||||
refuses(() => check(file(level)), /grants\[0\]\.level/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers tells a viewer grant from a collaborator grant", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
const collaborator = file("collaborator").grants[0];
|
||||
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
|
||||
// collaborator (design 9).
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
|
||||
});
|
||||
|
||||
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
|
||||
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
|
||||
});
|
||||
|
||||
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
|
||||
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const read = { level: "viewer" };
|
||||
assert.equal(access.covers(viewer, target, NOW, read), true);
|
||||
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
|
||||
// section 3.5, and covers(). Every test works in a scratch directory.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const P2 = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function context(over = {}) {
|
||||
return {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: {
|
||||
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
|
||||
acme: { human: "jason", instances: ["coder"] },
|
||||
},
|
||||
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function doc() {
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 3,
|
||||
users: [
|
||||
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
|
||||
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
|
||||
],
|
||||
grants: [
|
||||
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
|
||||
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mutated(change) {
|
||||
const d = doc();
|
||||
change(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
test("a valid document passes and comes back frozen", () => {
|
||||
const checked = checkAccess(doc(), context());
|
||||
assert.deepEqual(checked, doc());
|
||||
assert.ok(Object.isFrozen(checked));
|
||||
assert.ok(Object.isFrozen(checked.grants[0].projects));
|
||||
});
|
||||
|
||||
test("checkAccess does not freeze or alias the caller's document", () => {
|
||||
const d = doc();
|
||||
const checked = checkAccess(d, context());
|
||||
assert.ok(!Object.isFrozen(d));
|
||||
assert.notEqual(checked.grants, d.grants);
|
||||
});
|
||||
|
||||
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
|
||||
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
|
||||
assert.ok(Object.isFrozen(LEVELS));
|
||||
});
|
||||
|
||||
test("accessFilePath is access.json in the config directory", () => {
|
||||
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
|
||||
const dir = tmp();
|
||||
const saved = process.env.MOSAIC_CONFIG;
|
||||
process.env.MOSAIC_CONFIG = join(dir, "config.json");
|
||||
try {
|
||||
assert.equal(accessFilePath(), join(dir, "access.json"));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
|
||||
else process.env.MOSAIC_CONFIG = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
|
||||
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
|
||||
for (const bad of [0, -1, 1.5, "3", undefined]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
|
||||
});
|
||||
|
||||
test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
|
||||
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
|
||||
// The owner's own sign-in subject can't be an extra user.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
|
||||
// The same sub under another provider is a different pair.
|
||||
const twoProviders = context({ providers: ["authentik", "google"] });
|
||||
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
|
||||
});
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
|
||||
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
|
||||
});
|
||||
|
||||
test("the context is required and checked", () => {
|
||||
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
|
||||
for (const key of ["providers", "peers", "ssoSubjects"]) {
|
||||
const c = context();
|
||||
delete c[key];
|
||||
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
|
||||
}
|
||||
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
|
||||
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
|
||||
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
|
||||
});
|
||||
|
||||
// loadAccess: the file checks, then checkAccess.
|
||||
|
||||
test("loadAccess reads a valid file and checks it", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
assert.deepEqual(loadAccess(file, context()), doc());
|
||||
});
|
||||
|
||||
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
|
||||
const real = writeJson(join(dir, "real.json"), doc());
|
||||
const link = join(dir, "access.json");
|
||||
symlinkSync(real, link);
|
||||
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
const sub = join(dir, "d");
|
||||
mkdirSync(sub);
|
||||
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
});
|
||||
|
||||
test("loadAccess refuses another owner and group or other write", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
|
||||
for (const mode of [0o620, 0o602, 0o666]) {
|
||||
chmodSync(file, mode);
|
||||
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
|
||||
}
|
||||
chmodSync(file, 0o644);
|
||||
assert.ok(loadAccess(file, context()));
|
||||
});
|
||||
|
||||
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
|
||||
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
|
||||
});
|
||||
|
||||
// covers.
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
// Any level reads: the least level is viewer (row 193a).
|
||||
const READ = { level: "viewer" };
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target(), NOW, READ), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
|
||||
});
|
||||
|
||||
test("covers: level is the least level asked for (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
const v = { ...g, level: "viewer" };
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
|
||||
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
|
||||
assert.throws(() => covers(v, target(), NOW), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
|
||||
for (const bad of ["owner", "Viewer", "", null, 1]) {
|
||||
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
for (const bad of ["owner", "Collaborator", undefined]) {
|
||||
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
|
||||
});
|
||||
@@ -234,3 +234,36 @@ test("the shipped example refuses as written and validates once filled in", () =
|
||||
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
|
||||
assert.equal(id, 15);
|
||||
});
|
||||
|
||||
// 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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user