Compare commits

...
121 Commits
Author SHA1 Message Date
jason.woltje 3598839e63 docs: record #1847/#1848 fix row build, review and gate 2026-11-06 14:44:57 -06:00
jason.woltje c04529c7bb fix(bus): defer halted startup recovery; latest launch only gates notices (#1848)
Review round 1 on 4048e0f60 found two defects.

Startup recovery ran unconditionally. A continuation retry now refuses
while processing is halted, so a broker restart with a waiting
continuation failed with processing-halted. Startup recovery now waits
while processing is halted or invalid, as the periodic pass does;
processing.resume schedules it again.

#continuationStarting matched any launch without an end, so an older
launch whose end was lost (#1851) hid every later run's end. Only the
target's latest lifecycle event counts now.

Both regression tests fail on 4048e0f60 and pass here.
2026-11-06 14:26:12 -06:00
jason.woltje 4048e0f60c fix(bus,harness): stop INFO echo replies and duplicate continuation notices (#1847, #1848)
Expose the parent message class on agent delivery so the runner can stop automatic replies to answers while preserving requests and ordinary relays. Scope continuation retry versions to the target, suppress notices during processing halts and unclaimed launches, and clarify the instance-running refusal.

The bus suite excludes project-files.test.mjs because its 1 MiB socket test timed out on both the candidate and the untouched 1186cf7b1 base.
2026-11-06 14:14:53 -06:00
jason.woltje 1186cf7b1e fix(webui): record request misses only from the request read (#797)
The chat.js fetch wrapper recorded a request-not-found from /api/requests/<id>/workspace as a missing request and then served a synthetic 404 to the request panel's rereads, failing 11 request-browser tests. Only the request's own read records a miss now. Adds a regression test that fails on 9d3eb9e11, and bounds the r3 held-404 test's check on its refresh reads, which raced Files' cached repaint under load.
2026-11-06 12:34:00 -06:00
jason.woltje 9d3eb9e115 fix(webui): fence in-flight request misses on an observed state (#797)
A mosaic-request-state for an id now advances that id's own generation on every thread retaining it, so a 404 still in flight from before the observation cannot record the miss afterwards. Generations are pruned with the misses to retained rows. Adds a held-404 regression that fails on 16384bb12.
2026-11-06 11:15:15 -06:00
jason.woltje 16384bb120 fix(webui): retry missing requests on thread open and observed state (#797) 2026-11-06 11:06:04 -06:00
jason.woltje 97d670da94 fix(bus): name task evidence gaps and stop request polling (#797) 2026-11-06 10:49:33 -06:00
jason.woltje 6b84fe136e fix(webui): order same-key files settles by commit, not start 2026-11-06 09:35:25 -06:00
jason.woltje 86383a9d1e fix(webui): retain files content across redraws 2026-11-06 09:19:17 -06:00
jason.woltje 4cc3d05b3a Merge fix/write-record-object (a7417a1f2) into integrate/mvp3-ux-1106 2026-11-06 08:24:15 -06:00
jason.woltje 521b34bdd7 Merge feat/mvp3-project-archive-r9 (e75c33947) into integrate/mvp3-ux-1106 2026-11-06 07:50:10 -06:00
jason.woltje a7417a1f2e fix(project): accept JSON strings for records 2026-11-06 07:40:28 -06:00
jason.woltje c4dac3e6ee Merge fix/resume-chief (26f1218b9) into integrate/mvp3-ux-1106 2026-11-06 07:33:03 -06:00
jason.woltje e75c339470 fix(webui): withdraw request writes when a refresh resolves archived
A same-thread refresh kept the last resolved live controls until the session and history reads finished. When the fresh project lookup finds the project archived or the archive state unknown, withdraw the unguarded thread controls and remount the request panel fenced before those reads.
2026-11-06 07:15:31 -06:00
jason.woltje 26f1218b9c fix(bus): retry chief after processing resumes 2026-11-06 06:51:39 -06:00
jason.woltje 4c85d37842 Keep resolved archive state during thread refresh 2026-11-06 06:40:45 -06:00
jason.woltje 5bf07f35f0 fix(webui): retain archive state on failed refresh 2026-11-06 06:19:40 -06:00
jason.woltje 2511e79673 fix(webui): give archive controls the shared button treatment (#797) 2026-11-06 06:13:21 -06:00
jason.woltje 51892cf95a fix(webui): suppress writes on archived projects 2026-11-06 05:26:42 -06:00
jason.woltje 13f81354e8 Merge fix/ui797-supervisor-3t (cdf8dadc1) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
2026-11-06 05:03:52 -06:00
jason.woltje aa88548d64 Merge feat/ui797-stall-guard-int (8c568ea46) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
2026-11-06 05:03:42 -06:00
jason.woltje 8c3f48113f fix(webui): restrict writes when archive state is unknown 2026-11-06 04:48:14 -06:00
jason.woltje cdf8dadc1e fix: accept compatible supervisor store schemas (#797) 2026-11-06 04:10:29 -06:00
jason.woltje 37f38d8e10 docs: record archive integration verification 2026-11-06 04:02:29 -06:00
jason.woltje 8c568ea467 Merge integration head into #797 stall guard 2026-11-06 03:49:38 -06:00
jason.woltje 526d3e3b7d fix(webui): keep threads readable when archive lookup fails 2026-11-06 03:44:44 -06:00
jason.woltje 2cc938328c fix(webui): use integration routes for archived navigation 2026-11-06 03:41:23 -06:00
jason.woltje 6a655a5cbb Merge commit '5255e4e74171cb0f640a2fcd83c77806b5b6f3d6' into feat/mvp3-project-archive-r4
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
#	packages/bus/tests/request-continuation.test.mjs
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/index.html
#	packages/webui/src/serve.mjs
#	packages/webui/tests/browser.test.mjs
2026-11-06 03:32:15 -06:00
jason.woltje 6b2c925b95 fix(harness): fail 1 on an unmapped child signal (#797)
A signal name with no number in os.constants.signals now exits 1 instead of a made-up 128. Known signals keep 128+n (SIGKILL 137, SIGTERM 143) and the content-free stderr receipt. Test covers all three through the real entrypoint.
2026-11-06 03:22:31 -06:00
jason.woltje a3f4551406 fix(harness): back off only running instances (#797) 2026-11-06 03:01:12 -06:00
jason.woltje 006381cfc4 fix(harness): retain model child signal receipts (#797) 2026-11-06 03:01:12 -06:00
jason.woltje 478df1cacd fix(harness): preserve adapter text output bytes (#797) 2026-11-06 03:01:12 -06:00
jason.woltje f23a998eed fix: close project archive admission races (#797) 2026-11-06 02:54:04 -06:00
jason.woltje 5255e4e741 Merge fix/mvp3-req-texts-797 (d9b65961f) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
#	packages/bus/tests/request-continuation.test.mjs
2026-11-06 02:50:32 -06:00
jason.woltje d9b65961fc fix(bus): hold every quote fallback to the room (#797)
The oversize and unresolved ask-PM notices were appended without
checking the room left by the lead, so a lead with 0-130 bytes left
pushed a 972-task request past exec capacity (E2BIG at 131,086).
quoteBlock now returns at most max(room, 0): the full notice, the
47-byte short line, or nothing. Tests sweep rooms -3..160 for both
steps and run a 972-task lifecycle that must still exec.

BUILD-LOG appends a correction to the r4 limits (40,774 / 88,234).
2026-11-06 02:21:19 -06:00
jason.woltje 3e754c96a0 fix(harness): back off refused launches (#797) 2026-11-06 02:05:32 -06:00
jason.woltje c7ddc7d31c fix(harness): mark first streamed model output (#797) 2026-11-06 02:02:43 -06:00
jason.woltje 1c33c342bc fix(archive): fence new execution and preserve archived reads (#797) 2026-11-06 01:51:08 -06:00
jason.woltje 5df42478b6 fix(cli): answer native dialogs in the tester browser (#797)
A click on the test Console's Resume button opened window.confirm and the
browser daemon hung: the mouse release, screenshots and the frame tree all
block until the dialog is answered, and ui-browser had no way to answer it.

A click that opens a dialog now returns {dialog:{type,message}}. The new
command `dialog accept|dismiss` answers it and records the type, message
and answer in the receipt. While a dialog is open other commands except
stop refuse (journaled), and no screenshot or URL check is attempted.
A real-Chromium confirm test covers accept, dismiss and the refusals.
2026-11-06 00:13:16 -06:00
jason.woltje 3e21d239b3 feat(bus): owner project archive and unarchive (#797)
Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
2026-11-05 23:50:51 -06:00
jason.woltje 2297d532ea fix(bus): fit the whole requirement quote to the worker request (#797) 2026-11-05 23:37:55 -06:00
jason.woltje 5e36a395b4 Merge feat/ui797-support (22e3d2188) into integrate/mvp3-ux-1106
Support r6 APPROVE (independent Codex review). Gate on Node 26.12.1:
budget-supervisor 97/0, isolated-identity 8/0, ui-browser 13/0,
request-lifecycle-live 2/0. Append-only BUILD-LOG.md and docs/SESSIONS.md
conflicts keep both sides.
2026-11-05 23:36:40 -06:00
jason.woltje 3c55f5b25d fix(webui): tolerate a room whose project is not in the loaded list (#797)
The room context event read project.repo without a guard, so a room
outside the loaded project list threw and failed the room load.

Gate: full WebUI with the editor build required, 633/0 on the second
run. The first run failed I1 inbox in decide-browser.test.mjs once;
three isolated runs each on this tree and on 5a346825b passed 9/0.
2026-11-05 23:24:32 -06:00
jason.woltje 22e3d21889 fix(cli): require owner-named pin targets and fixed serve paths (#797)
A pin corroborates a ledger target only when its binding names its own
campaign and the owner campaign. serveBudget freezes the startup campaign
and ledger paths and retains the ledger with its startup target; a running
service accepts its exactly unchanged startup ledger. Without retained
evidence an owner business/config retarget now refuses as ambiguous.
2026-11-05 23:22:32 -06:00
jason.woltje 1eb3940874 Merge fix/w15-integration-r1 (b64773956) into integrate/mvp3-ux-1106 2026-11-05 23:18:51 -06:00
jason.woltje b64773956e fix(webui): keep remote draft, room context and drawer focus across polls (#797) 2026-11-05 23:18:04 -06:00
jason.woltje b3ee8a2bcd fix(bus): bound requirement quote by encoded bytes (#797)
The r2 review found that counting raw bytes let JSON and control
escaping grow one quote six-fold, so the runner's MOSAIC_REQUEST
exceeded the exec string limit (E2BIG). The continuation renderer now
applies the 16384-byte budget to encoded title and text bytes, cutting
on whole code points. Adds an execution-boundary regression.
2026-11-05 23:04:31 -06:00
jason.woltje 85e1ec5d15 fix(cli): refuse conflicting ledger targets before halt (#797)
A valid owner pin and a v2 ledger that disagreed on target still halted
the ledger's binding, so a business-only edit or a foreign ledger could
redirect the stop. The ledger target now needs owner corroboration;
otherwise the owner-verified pin replaces it, or the check refuses.
2026-11-05 22:55:43 -06:00
jason.woltje 5a346825b7 Merge integrate/w15-slices (70443895e) into integrate/mvp3-ux-1106 2026-11-05 22:40:07 -06:00
jason.woltje 27315d4961 Merge fix/mvp3-coder-stall-797 (8d27a82ea) into integrate/mvp3-ux-1106
Gate on Node 26.12.1, 31 files: 422 pass, 1 fail. The failure is the
row 66 unslop timing flake in packages/harness/tests/claude-session.test.mjs,
which fails about 1 run in 3 on the base too; the file rerun passed 14/0.
Append-only BUILD-LOG.md and docs/SESSIONS.md conflicts keep both sides.
2026-11-05 22:39:56 -06:00
jason.woltje 70443895e4 feat(webui): preserve drawer session context (#797) 2026-11-05 22:35:41 -06:00
jason.woltje fce831c053 fix(cli): confine browser mounts and keep halt on binding damage (#797) 2026-11-05 22:34:36 -06:00
jason.woltje 6eee6b64ce feat(webui): connect W15 slices (#797) 2026-11-05 22:29:12 -06:00
jason.woltje 50a8d1a43a fix(bus): contain requirement quotes and refresh continuation state (#797) 2026-11-05 22:10:51 -06:00
jason.woltje 8d27a82ea7 fix(harness): acknowledge Claude helper completion after stdout drains (#797) 2026-11-05 22:10:37 -06:00
jason.woltje 89b0de91f3 Merge feat/ux816-w15-project-remote-r2 (18df6e049) into integrate/w15-slices
# Conflicts:
#	packages/bus/src/broker.mjs
#	packages/bus/tests/console-decide.test.mjs
#	packages/bus/tests/request-console.test.mjs
#	packages/webui/src/cli.mjs
#	packages/webui/src/serve.mjs
2026-11-05 22:08:48 -06:00
jason.woltje 59602c9105 Merge feat/ux816-w15-files-r2 (551acdf3d) into integrate/w15-slices 2026-11-05 22:08:28 -06:00
jason.woltje 07ea16d390 Merge feat/ux816-w15-inbox (7a2dc5bad) into integrate/w15-slices
# Conflicts:
#	packages/webui/src/public/bus.js
2026-11-05 22:08:28 -06:00
jason.woltje 307dad8713 Merge feat/ux816-w15-requests (ea0b67654) into integrate/w15-slices 2026-11-05 22:08:13 -06:00
jason.woltje 316004ee4c Merge feat/ux816-w15-conversations (cecfcdd20) into integrate/w15-slices 2026-11-05 22:08:13 -06:00
jason.woltje d51bc4893a Merge feat/ux816-w15-shell-r3 (3fd82b084) into integrate/w15-slices 2026-11-05 22:08:06 -06:00
jason.woltje 18df6e049c feat(webui): enable owner project remote save (#797) 2026-11-05 22:04:07 -06:00
jason.woltje 905d313cdf fix(cli): isolate browser network and halt on ledger loss (#797) 2026-11-05 21:52:54 -06:00
jason.woltje 551acdf3d3 feat(webui): list verified draft workspace names (#797) 2026-11-05 21:52:26 -06:00
jason.woltje 3fd82b0840 feat(webui): W15 shell rail, drawer and Projects nav (#797) 2026-11-05 21:50:48 -06:00
jason.woltje 6c123ce671 fix(harness): report Claude helper boundary failures (#797) 2026-11-05 21:44:24 -06:00
jason.woltje aca8ce0599 Merge fix/mvp3-planning-797 (c47a67de8) into integrate/mvp3-ux-1106
Gate on the merged tree: business 307/0, bus 1596 pass with one
pre-existing hang (project-files.test.mjs, same on relay base 2f1cef4c1),
harness 243/1 on the row 66 unslop test, which also fails 1 in 3 runs
on the relay base (timing flake, pre-existing).
2026-11-05 21:44:04 -06:00
jason.woltje 92fe6c9303 feat(webui): add guarded project remote writer (#797) 2026-11-05 21:25:47 -06:00
jason.woltje ea0b676541 feat(webui): update request presentation (#797) 2026-11-05 21:23:14 -06:00
jason.woltje 19e6cb0f1e fix(cli): address ui797 support review findings (#797) 2026-11-05 21:21:50 -06:00
jason.woltje cecfcdd201 feat(webui): add project conversation surfaces (#797) 2026-11-05 21:18:46 -06:00
jason.woltje 6e5cf26e7a fix(bus): quote approved requirement texts into coder/reviewer continuations (#797)
D3: coder/reviewer sandboxes cannot read the product records repo and
list_requirements stays PM/CTO-only (row 90, #1584). The bus now quotes
the task-cited approved requirement texts (exact text, id, title, citing
task refs, snapshot id, product key, records commit) into the
implementation and review continuations, resolved host-side through the
same catalog resolver behind the scoped requirements verb, never from
model input. Quotes are bounded at 16384 bytes and framed as quoted
data; an unresolvable or revoked snapshot degrades to an ask-PM line
without blocking the task. No role, tool or schema widened; one duty
note each in roles/coder.md and roles/reviewer.md.
2026-11-05 21:18:26 -06:00
jason.woltje 8094901bce fix(harness): preserve failed Claude result diagnostics (#797) 2026-11-05 21:17:18 -06:00
jason.woltje 191061a9e7 feat(webui): add W15 shell candidate (#797) 2026-11-05 21:15:36 -06:00
jason.woltje 7a2dc5bad1 feat(webui): show decision origin and receipt evidence (#797) 2026-11-05 21:13:45 -06:00
jason.woltje 59801efe4d feat(webui): add safe reviewed file text view (#797) 2026-11-05 21:12:47 -06:00
jason.woltje bd8340b9b8 feat(cli): add isolated ui797 acceptance support (#797) 2026-11-05 20:53:44 -06:00
jason.woltje c47a67de8d fix(bus): keep the coverage refusal when its notice fails (#797)
Review M1 on 62bece4a9: a failed notice insert or route threw past
#refused, replacing request-evidence-required and skipping its audit.
The notice is now best-effort; its transaction still rolls back whole.
Tests: insert and route failures keep code, audit and lifecycle; retry
publishes once. Both red on 62bece4a9.
2026-11-05 20:47:27 -06:00
jason.woltje 1bd0077391 fix(harness): mark model activity before final answers (#797) 2026-11-05 20:45:03 -06:00
jason.woltje 2f1cef4c1d fix(harness): expose trusted assistant coordinator context (#797) 2026-11-05 20:39:48 -06:00
jason.woltje 62bece4a9d fix(lifecycle): explain incomplete planning coverage (#797) 2026-11-05 20:28:16 -06:00
jason.woltje 29a3cc6323 Fix MVP intake relay receipts and role guidance 2026-11-05 20:17:23 -06:00
mos 05fdfef9ea feat(skills): add reusable UX improvement workflow (#1845)
Capture task-grounded research, preserved evidence, wireframes before graphical
concepts and independent assessments with separate author/evaluator contexts.
Document frozen-method reuse, scoped feedback, null/zero arithmetic, coverage
and stopping rules without requiring a particular model, harness or subscription.

Personally authored by a scoped Codex agent. Fresh independent source and
forward-use review APPROVE; 14 package-focused checks pass. Retain the synthetic,
single-model limits and separate installation/activation authority.

Package manifest: b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140
2026-11-04 21:27:44 -06:00
mos bd8120318b fix(lifecycle): resume requests when evidence arrives (#1839)
Wake the existing PM when validated evidence arrives after its first notice,
including overlapping recovery across retained request history. Preserve durable
deduplication and current authority checks.

Put the exact verification record and verdict contract in the first scoped
review request. Carry linked owner-request provenance into runner headers and
expose the existing requirement-record example to planning tools.

Implementation: Codex Terra initial repair; Codex Sol lead R1 correction,
regressions and developer documentation. Independent R2 APPROVE, 115 focused
checks. Matching final local coverage proves 4,333 distinct required cases;
the intentional FEDR7 skip is covered by its standard scratch wrapper.

Reviewed patch: 18ff6455e7ab69bc55cef88845f86189ed5f67b114ad7996f714a35ca1a90239
Source manifest: 6daf0668101bbad030c45411304d0d049de5e4da32bdcb03c40095ed1c63128f
2026-11-04 21:08:42 -06:00
jason.woltje e9271841f3 fix(webui): enable factory chat and recover accurate states (#1843)
Enable CLI and WebUI channels in the Case 1 starter. Explain channel
refusals, preserve in-memory drafts, and distinguish failed reads from
successful empty data. Keep room refusal state through failed refreshes
and restore session controls after catalog recovery.

Integrates reviewed source commits 5558fe020 and a1ba9cd56. Independent
review R2 approved both recovery fixes; append-only records preserve
controller and implementation evidence.
2026-11-04 17:27:22 -06:00
jason.woltje a89139e5ea docs: record live WebUI channel recovery (#1843) 2026-11-04 16:49:06 -06:00
jason.woltje 3572bf9b32 docs: diagnose MVP 3 WebUI channel failure (#1843) 2026-11-04 16:39:00 -06:00
jason.woltje 51525a31d6 test(lifecycle): cover three delivery paths and crash recovery (#1839) 2026-11-04 14:47:45 -06:00
jason.woltje 81e54bf4d8 feat(bus): attach requests to existing approved products (#1839) 2026-11-04 13:52:36 -06:00
jason.woltje 76f8d3654f fix(bus): bind delivered review to task closure (#1839) 2026-11-04 13:31:15 -06:00
jason.woltje aa9b91d88b feat(bus): record reviewed delivery and let the owner fetch the artifact (#1839) 2026-11-04 13:31:14 -06:00
jason.woltje f7378f365c fix(release): read inline migration pairs in the release chain reader (#811) 2026-11-04 13:20:11 -06:00
jason.woltje 34e9af6167 feat(harness): let the PM ask the owner an intake question (#1839) 2026-11-04 12:42:34 -06:00
jason.woltje 48ec9721d1 feat(console): move the model proxy page into Settings (#803) 2026-11-04 12:32:59 -06:00
jason.woltje 647626bdd1 feat(bus): resume product requests through typed role continuations (#1839) 2026-11-04 12:18:10 -06:00
jason.woltje 68bef84fa7 feat(console): show the PRD draft and revoke an approved snapshot (#1839) 2026-11-04 11:54:11 -06:00
jason.woltje b5a58b48b4 feat(tasks): bind requirement and git admission to product scope (#1839) 2026-11-04 11:24:39 -06:00
jason.woltje 37d44f36e0 docs: record the Task 3 rebase onto c098fa022 (#1839) 2026-11-04 11:21:43 -06:00
jason.woltje 0244917e64 fix(console): map Task 4's mode refusal and read its draft shape in the request routes (#1839)
The bus refuses a stale mode revision as request-mode-revision-conflict, so the Console route maps that
code to 409 instead of request-mode-stale and request-mode-unchanged, which no bus emits. The mode copy and the
request-mode-changed wait label describe Task 4's project-level change. The draft route relays Task 4's
{ candidate, prd, requirements } reply when its candidate names the request, and maps prd-candidate-required to 409.
2026-11-04 11:21:43 -06:00
jason.woltje 4e79eaace1 fix(console): check the owner-to-PM edge on every intake control, refuse request URLs without SSO, bound the browser waits (#1839)
Review r1 of lifecycle Task 3.

C1: the four owner controls (answer, attach, mode, cancel) inserted and routed a
human-to-PM message without the messaging policy check message.send makes. The
shared writer now reads the current human-to-PM edge inside the control's
transaction, before it inserts or routes anything, and refuses with
message-sender-refused. The refusal rolls back the journal row, the human.input
event, the message and a withdrawn selection decision. The cancel withdrawal
checks the same edge. Regression tests close the edge after the question and
cover all four controls, then reopen it and prove the same call lands once.

I1: serve.mjs read sso.publicOrigin for a request URL when the Console ran
without --sso (or in preview mode), which ended the process on an unauthenticated
GET /api/requests/r1. A request route now answers through requestHTTP with no
transports and no SSO fields: unavailable on a read, read-only on a write, 405
for the wrong method. Listener tests cover GET and POST without SSO and in
preview mode, named and loopback, and the process keeps serving.

M1: the browser test waiter allowed about 3 s and reload() could run out its loop
without failing. Waits are now deadline-bound at 15 s and name the expression,
and reload() waits for the new document's own ready flag and the mounted chat,
and throws if it never gets there. The no-repost and stored-answer assertions of
the lost-answer case are unchanged.

Migration 3p-3q is unchanged.
2026-11-04 11:21:43 -06:00
jason.woltje eb551b1f89 feat(console): choose a request mode, answer questions and confirm attachment with buttons (#1839)
The owner handles a new request from the Console: pick the mode (new
autonomous, new guided or an existing project), answer the project
manager's questions and confirm an attachment, with no copy-to-terminal
step.

Bus: 3p->3q append-only migration widens the request_events kinds. The PM
asks one question with request.question. The owner-only
request.answer.console, request.attach.console, request.mode.console and
request.cancel.console join the two console reads in CONSOLE_VERBS. An
answer is an attributed human.input linked to its question and request,
recorded once per op and never a PRD approval. An attach pick makes the PM
propose that project as a confirmation decision; nothing is mapped on the
pick. A mode change takes the expected revisions and leaves the bound plan
as evidence. Cancel keeps a created project.

Console: request-http.mjs serves GET /api/requests/:request, /projects and
/draft and POST /answer, /attach, /mode and /cancel with the signed-in
owner, same-origin and bounded-body checks of the decision routes.
requests.js draws the panel under the owner's chat message, answers the
mode decision through the existing confirmation endpoints, and after a lost
answer response reads the request first and resends the same op only on the
owner's click.
2026-11-04 11:21:43 -06:00
jason.woltje 793f3b157d fix(cli): upgrade and roll back Case 2 proxy hubs (#772) 2026-11-04 11:07:57 -06:00
jason.woltje c098fa022c feat(bus): approve and activate an exact product PRD version (#1839) 2026-11-04 10:49:15 -06:00
jason.woltje ea0e885bc9 fix(bus): keep foreign working repos refused, hold the organization lock through protected commits, fence serving on the activate receipt (#802) 2026-11-04 10:38:54 -06:00
jason.woltje 7f5bbbf747 fix(bus): confine the working repo, serialize freshness with protected writes, fence readiness on publication (#802) 2026-11-04 10:38:54 -06:00
jason.woltje 1031686140 feat(tasks): recover an uncertain tracker create without a second post (#1839) 2026-11-04 10:33:10 -06:00
jason.woltje 6f41eddb20 fix(harness): keep tool input schemas free of top-level oneOf (#1839) 2026-11-03 21:55:20 -06:00
jason.woltje 1de685ccad feat(harness): give the PM typed intake tools for pre-task requests (#1839) 2026-11-03 21:53:15 -06:00
jason.woltje 1648af7d4e feat(seat): read one reviewed artifact blob with bounded safe Git reads (#1839) 2026-11-03 21:46:53 -06:00
jason.woltje 6dd4f92b0a docs: record lifecycle Task 2 review, fix round and landing decision (#797) 2026-11-03 20:51:19 -06:00
jason.woltje 8830030412 fix(bus): confine bootstrap writes, keep unproven partials, recheck freshness per effect, wire host preparation (#1839) 2026-11-03 20:41:46 -06:00
jason.woltje c558498062 feat(bus): bootstrap a confirmed new project and admit it at runtime (#1839)
Lifecycle Task 2 (#797). A confirmed new-project selection now becomes a
usable product: the code checkout, the organization mapping, the records
repository with the original intent as the owner's spec commit, the chat
association and the working repository, each journaled as its own receipt
(bootstrap:<plan_digest>:<step>) and recoverable after a kill.

- business: addProject is a private exact-add writer under the existing
  organization lock and receipt discipline; hold runs a synchronous check
  under that lock; findAddition recovers a prepared receipt. Ordinary
  writers still refuse every project change.
- bus: prepareBootstrap and executeBootstrap; activateManagedProject
  reloads only a receipt-backed additive root and admits it to the broker
  after readiness; request.admission host IPC op for the launcher.
- #800: the bound organization revision and association are re-verified
  inside the organization lock at the start of execution; a mismatch
  records request-plan-stale and performs no effect.
- cli: trackerFor skips reserved products; the launcher refuses to clone a
  product the bus has not admitted.
- seat: initializeWorking seeds the working repository, host-only, with no
  work ref or push authority.
- schema: append-only migration 3o to 3p widens the request_events kind
  list with bootstrap.stale and bootstrap.completed.
2026-11-03 19:50:32 -06:00
jason.woltje 138171ca0d docs: record lifecycle Task 1 review, fix round and landing decision (#797) 2026-11-03 18:21:15 -06:00
jason.woltje bec3583286 fix(bus): keep the request route across setup waits and recheck association after admission (#1839) 2026-11-03 18:15:48 -06:00
jason.woltje 3b071cac7b feat(bus): persist pre-task request intake and selection (#1839) 2026-11-03 17:53:45 -06:00
jason.woltje 1e1357cb6e feat(console): answer every eligible decision option (#1839) 2026-11-03 17:25:24 -06:00
jason.woltje 831d15f0b4 docs: prepare lifecycle execution and original request evidence (#1839) 2026-11-03 17:01:26 -06:00
jason.woltje 63bd48a5f8 docs: specify working repository bootstrap interface (#797) 2026-11-03 16:59:51 -06:00
jason.woltje 260f2ce7ed docs: plan request lifecycle implementation and PRD addendum (#797) 2026-11-03 16:59:51 -06:00
jason.woltje a34f79692e docs: record user terminology migration inventory (#1840) 2026-11-03 16:52:09 -06:00
jason.woltje 930d77eeca docs: track user terminology and identifier migration (#1840) 2026-11-03 16:46:04 -06:00
250 changed files with 24348 additions and 517 deletions

No files matched your search

+341
View File
@@ -5979,3 +5979,344 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-03 | Lead Codex, request-to-product design handoff (Vikunja #797, Gitea #1839) | After: docs/plans/2026-11-03_request-to-product-design.md proposes general Console answers first, durable pre-task intake and three modes, typed project bootstrap, project-scoped approved PRDs and automatic continuation through reviewed delivery; original pomodoro is the acceptance case. Opus independent builder check also failed Claude rate limit; lead self-check only, no independent approval. Await owner decision on concrete authority/schema scope and temporary GPT implementation delegation while Claude is unavailable. Documentation only; product behavior and acceptance not claimed.
2026-11-03 | Lead Codex, #797 / Gitea #1839 implementation start | Jason reports CLI Proxy API leaves Claude accounts unauthenticated and authorizes GPT agents for these tasks. Execute the staged design with GPT coding and independent review; general Console buttons first under existing REQ-WEB-2, detailed lifecycle/PRD plan in parallel. No further Claude calls or production-host changes.
2026-11-03 | Lead Codex, terminology migration discovery (#798, Gitea #1840) | Before: user-facing prose, role contracts, owner principal, config human key and events/transports use legacy human terminology; a separate user principal already means a granted user. Jason explicitly includes internal identifiers in the enhancement. Created #798 and #1840; Luna will inventory a compatible staged rename after #797, with no change to authority or immutable historical evidence.
2026-11-03 | Lead Codex, #798 / Gitea #1840 inventory outcome | Luna read-only report completed with compatibility concerns. Lead verified owner-vs-granted-user principal split, business config key, optional restricted role messaging and human.input schema/trigger; recorded corrected scope and staged migration in docs/plans/2026-11-03_user-terminology-migration.md. No runtime/code/config rename performed. Implementation follows #797; stronger GPT review required for schema/proof/history readers. Queue refusal in linked inventory checkout was not bypassed.
2026-11-03 | Lead Codex, #797 / Gitea #1839 lifecycle plan integration | Integrated Astra planning commits as 260f2ce7e and 63bd48a5f; eight bounded implementation tasks and proposed exact PRD addendum, no production code. Lead checked project-map/runtime/requirement/continuation interfaces and retained the exact PRD adoption gate. Preserved the original pomodoro wording and owner-reported event/message IDs from the original conversation item as an explicitly labeled quotation export, not a raw runtime row/live receipt. Console implementation remains a separate active task; lifecycle implementation follows its review/integration.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 before | Prepared checkout t3/b15e72fa at 7e849a7b0b635d34950a5c5c2e4b78c4238fe651. Console answers currently require two-option grant requests. Scope: ordinary 2–9 choices through existing preview/confirmation/resolve, preserved grant and identity gates; no tracker/role/PRD/runtime changes. Baseline bus 12/12; initial WebUI 12 pass/5 fail from missing oauth4webapi@3.8.8, restored with locked npm ci --ignore-scripts, then WebUI 17/17 including Chromium. Intended RED: broker 14 pass/3 fail decision-not-eligible; HTTP/browser 12 pass/5 fail ordinary confirmation 503 or absent option panel; real SSO browser 0 pass/1 fail zero buttons instead of three. Owner continuation asks for user in changed prose, technical identifiers retained for #798.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 after | Ordinary user-routed decisions now expose authoritative kind/options through the existing two Console verbs; confirmation uses the selected key/text and ordinary receipts say Recorded. The digest binds the complete decision row and raise authorization/grant context; stale membership/context refuses transactionally. Grant approve/deny/summary and one-use executor path, OIDC/session/origin checks, role attribution and authority remain intact. Cancellation, navigation, reload, closure and changed-context refresh drop UI confirmations without replay. Added real fake-OP signed-in Chromium -> HTTP -> broker third-option coverage: one resolution, verified-subject audit, raiser notice and zero grant activity. RED-to-GREEN also checked project/spec digest binding. Targeted broker 17/17 and WebUI 25/25; final full bus 1407/1407 and WebUI 509/509, zero failures/skips. First full runs: bus 1406 pass/1 fail at unchanged host-run T27 sweep (expected one removed dir, got []), isolated T27 group 4/4 and full rerun green without changing it; WebUI 507 pass/1 fail/1 skip due absent package-local esbuild@0.28.2, restored with its lockfile, isolated build/provenance 7/7 and full rerun green. A newly added SSO test transport initially used nonexistent broker.release; corrected to disconnect after diagnosing its timeout, without weakening assertions. No independent reviewer launched; controller provides review. No tracker, role JSON, PRD, host/service/config/secrets changes, push, merge, PR or deployment. Internal identifier migration remains #798 / Gitea #1840.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 before | Fix base 7b51dbbba4778e7cb4d171dbf4c6e10f011b926e in the original prepared t3/b15e72fa checkout. Verify Important I1 in the existing Chromium harness: an obsolete A inbox/trail read (including kept-data fallback on error) must not replace shared answer state behind B controls. Scope is the decision state/render generation boundary only; controller supplies re-check. M1 editor-build warning remains deferred; no bus, lifecycle, rename, dependency, tracker or live operations.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 after | Verified Important I1 with three real Chromium regressions on 7b51dbbba: delayed A inbox, delayed A trail and failed delayed A trail with cached fallback all left B displayed but sent A confirmation URLs (0 pass/3 fail, no fixture/module errors). Root cause: decisionView mutated shared answer before render discarded obsolete HTML; fallback could start after a newer render too. Pass the originating render validity closure (generation, exact route, signed-in state) through both live and kept-data view paths and check before decision answer-state mutation; use the same check before committing page HTML. Small decision-state fix only; no renderer cleanup. GREEN I1 3/3; covering browser/SSO/HTTP 28/28; full WebUI 512/512, zero failures/skips/cancellations. Full output retains M1 ignored-bare-import warning at unchanged prosemirror-transform mark.ts import; deferred per controller. No bus file changed or bus suite rerun, no lifecycle/editor/rename/dependency/tracker/live changes, no subagent or reviewer, no push/merge/PR. Controller supplies fresh re-check. Prior evidence preserved; round 1 logs/report under /mnt/storage/scratch/tmp/mosaic-797-.
2026-11-03 | Lead Codex, Console stage 1 integration (#797, Gitea #1839) | Squash-integrated reviewed worker range 7e849a7b0..95b867cf8 onto refactor 831d15f0b. Only BUILD-LOG/SESSIONS conflicted; preserved canonical bytes and appended worker deltas. Every non-log changed file byte-matches reviewed 95b867cf8. Independent R1 found I1 delayed-read answer-state race; fix 7b51dbbba..95b867cf8 has real-browser RED 0/3 then GREEN 3/3; independent R2 I1 ADDRESSED, no new breakage. Fresh sequential integrated suites: bus 1407/1407 and WebUI 512/512, no failures/skips (scratch mosaic-797-integrated-{bus,webui}.log). M1 unchanged editor-build warning deferred; original T27 transient retained as an unproven timing observation. No approved-PRD/role/schema/config/host/deployment change. Broader lifecycle remains open.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 lifecycle Task 1 before | Prepared t3/0c734dbd checkout at assigned base 1e1357cb6e97825f6080bee15ab45efce794b137. Scope: durable pre-task intake journal, strict 3n→3o offline migration, trusted input provenance, host lifecycle foundation and authorized request.open/get/projects/propose. Three enumerated choices remain user-gated via project.bootstrap in both closed action lists only. Later resource creation, PRD activation, scoped tasks, launches and delivery remain outside this unit; absent host preparation/admission capabilities must leave a durable setup wait. Controller owns queue/tracker coordination and independent review. Red tests precede implementation; local tested commit only.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 lifecycle Task 1 after | Added host-only createRequestStore/createRequestLifecycle, strict CAS transition reduction, byte-preserving intake from existing user-origin human.input/message records, immutable mode/association plan bindings, latest-evidence effect reservations, and broker-derived authorization for request.open/get/projects/propose. Autonomous/guided/existing are enumerated selections; existing stages a filtered software-project picker and a separate exact-association confirmation. PM preparation uses existing message.send authority and rechecks its live claim after awaits; only the owner resolves the user-routed nonblocking project.bootstrap decisions, with null task/requirement refs. No resource creation, PRD activation, scoped tasks, role launch or artifact execution. Missing preparation/admission/bootstrap/attachment remains an explicit setup wait; oversize full resource displays retain source bytes and wait at the existing decision size limit. Added five append-only lifecycle tables, immutable new 3n→3o migration, strict source/digest/version checks and fixture-only offline CLI/old-engine refusal evidence. Existing migrations, approved PRD, role JSON, Console code/verbs, queue/tracker and live config/service/secrets untouched. Minimal collateral: older migration test expectations advance to 3o; role Markdown names the newly gated action; stack-release restore counting includes all five new timed tables. RED intake 0/12; further binding RED 15/16, origin/association RED 21/23, setup/display RED 23/25 and receipt/path RED 23/25. Final intake 26/26; required intake/schema/store/hard-waits 41/41; full bus 1433/1433; full business 296/296; bounded CLI restore/accounting 7/7; sandbox import closure 1/1, all zero failures/skips. Prior full bus 1389 pass/30 fail comprised 27 changed-schema/closed-vocabulary expectations and three managed-launch failures traced to harness-binary-missing; restored existing pinned dependencies with npm ci --ignore-scripts, lockfile unchanged, then full bus 1425/1425 and final 1433/1433. Prior business 295/296 required the two existing hard-wait prose lists to match the closed vocabulary. Test fixture setup mistakes (tester switch, halted-before-launch and incomplete library mapping) were corrected without weakening assertions. Retained evidence: /mnt/storage/scratch/tmp/mosaic-797-task1-logs/; report: /mnt/storage/scratch/tmp/mosaic-797-lifecycle-task-1-report.md. Controller owns independent review and integration; local commit only, no push/merge/PR/deployment.
2026-11-04T00:20:48Z | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 / Gitea #1839 lifecycle Task 1 landing | Review R1 (GPT-6.1 Sol): Needs fixes, I1 PM self-citation reachability, I2 existing route lost after setup waits, I3 stale association confirmed after async admission. I1 deferred to tracker #799 as a security follow-up (MVP rule). Fix round 1 (Sonnet 5.5 xhigh) bec358328: RequestState.route from selection.staged; staleCode recheck before admission and inside the recording transaction for propose and resume; 12 regressions that fail on 3b071cac7. Gates on bec358328: intake 38/38, full bus 1445/1445, business 296/296, zero skips (/mnt/storage/scratch/tmp/nlR5yC). Review R2 (GPT-6.1 Sol): I2 resolved; I3 partly resolved, an in-process change in the continuation after the final awaited inventory read still reaches proposal or confirmation (repro /mnt/storage/scratch/tmp/C5CJ6q). Lead decision: land 3b071cac7+bec358328 without a second round; residual is tracker #800, and the Task 2 executor must re-verify the association at execution. Tradeoff: confirmation records consent, not freshness, until #800 lands. Plan doc RequestState gains route.
2026-11-04T01:50:11Z | Claude Sonnet 5.5 implementation sub-agent, t3/request-lifecycle-bootstrap | #797 / Gitea #1839 lifecycle Task 2 after | Protected project bootstrap that becomes usable at runtime, on 138171ca0. Organization writer: addProject (private exact-add path under the existing lock and receipt discipline, input and serialized bytes both compared against the plan, replacement/removal/library/unrelated-key changes and a mismatched receipt refused, stale revision refused), hold (sync check under the lock), findAddition; write still refuses every project change (organization-projects-protected). New bootstrap executor journals reserve, seed, working, mapping, records, intent, chat and activate as separate receipts bootstrap:<plan_digest>:<step>; the code seed is published before the mapping and records init runs under the published mapping; interrupted effects are compared by deterministic path, stored commit and exact bytes and retained as bootstrap-uncertain, never removed or rewound. Original displayed intent is saved with writeHumanFile as the decision owner, writeRecord still cannot write spec/. Tracker: the product file omits tracker.project, the board lives in the immutable plan and admission projection, trackerFor skips reserved products and a fresh boot verifies product IDs. Runtime: activateManagedProject reloads only a receipt-backed additive root inside <dataRoot>/products after revalidating the full business, prior mappings, credentials, bots, libraries and roles; broker admission and the launcher (via host IPC request.admission) refuse a pending product until activation; restart rebuilds readiness from receipts. initializeWorking (host-only, seat/working.mjs) seeds the working repository under the existing repo lock with no work ref or push authority. #800: the bound organization revision and association are re-verified synchronously inside organization.hold at the start of execution; a mismatch records setup wait request-plan-stale and performs no effect (regression: an_organization_change_after_confirmation_waits_without_effect and four sibling stale cases). Schema change was needed and is append-only: migration 3o to 3p widens the request_events kind CHECK with bootstrap.stale and bootstrap.completed (old table renamed away, new table created under its final name, rows and seq copied, guards recreated); 13 migration-pin tests moved 3o to 3p the way Task 1 did for 3n to 3o, with a new 3o_to_3p test (rows and seq kept, guards intact, the 3o engine refuses the migrated database). RED: project-bootstrap suite failed on writer.hold missing; bus suite failed 9/10 on the kind CHECK before 3p. Corrections: the first full bus run exposed the CHECK; a test expectation for a replayed revision named the revision guard when the append-only trigger fires first (test fixed, schema unchanged); the first-boot test opened a second broker before closing the first (writer-locked, fixture fixed). GREEN: brief command 18/18, kept-green trio 49/49, business 304/304 (was 296), bus 1456/1456 (was 1445), project+launcher 80/80, stack-release 225/225, related cli suites 422/422, seat 223/223, zero skips. Logs /mnt/storage/scratch/tmp/BKItrk/. Not executed on any running instance; local commit only, no push/merge/PR/deployment.
2026-11-04T02:45Z | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | One fix round on c55849806 for review findings C1, C2, I1, I2, I3, I4; Minor items, Tasks 3-8, roles/*.json, ROUTED and all migrations untouched (no schema change). C1: seeding no longer follows a committed .mosaic link. Linked ancestors and non-regular metadata are refused in the source and in the clone, and the seed and the marker temp are written with O_EXCL|O_NOFOLLOW and renamed, so a planted link is never followed. C2: the clone goes into an exclusively created code.tmp backed by a code.tmp.claim receipt; an unclaimed or foreign-content code.tmp is kept untouched with bootstrap-uncertain, a claimed clone left by a kill is adopted exactly once, and no deterministic path is removed recursively. I1: the #800 freshness check runs before every effect including recovery effects; a mismatch appends setup wait request-plan-stale and does nothing further, evidence kept. I2: managedState classifies by layout (entry root and repo match the managed paths) and reads readiness from the marker and the committed activate receipt, so a missing bootstrap.json no longer admits an unfinished product; the launcher and trackerFor already call managedState and needed no change. I3: new packages/bus/src/request-host.mjs wires preparePlans and admitPlan in the real host from the organization document and revision, the booted tracker config and the staff the organization names; a missing tracker root or staff is a named setup wait (request-tracker-unavailable, request-staff-unavailable, request-organization-unavailable, request-organization-changed) and creates nothing; process.mjs now forwards trackers to startBroker. A normal boot with a tracker root and named staff reaches a bootstrap selection and bootstraps the project. I4: the Task 1 existing-project comparison was reversed and is fixed in localPlan with regressions in request-intake. RED/GREEN: each regression was failing on c55849806 and passes now; the regression set was mutation-checked by reverting each guard, with two exceptions recorded honestly: the executor-level metadataProblem mutant (c1-exec) still passes because git status is a second guard, and the i3 admitPlan unit test was not mutation-checked. Corrections: the committed project-file link test first failed at boot because the config loader refuses the link (test now commits the link after boot); the c2-claim mutant first survived because clone verification is a second guard (two unclaimed-code.tmp tests added); boot recovery rewrote a complete marker for a deleted reserved one, fixed by requiring the marker to prove ownership before every post-reserve effect; the i2 assertion on admission revision null was dropped because that Minor item is out of scope. First full bus run had one failure, model-final-completion 656 FIFO niceness (startNice 16 !== 19), unrelated to this change; the file passed 21/21 three times alone and the full rerun was 1477/1477. GREEN: brief trio 79/79, kept-green trio 49/49, bus 1477/1477 (was 1456), business 305/305 (was 304), project+seat 267/267, stack-release 225/225, zero skips. Limits: the selected tracker board is unverified until the first task is created; after a post-effect stale wait nothing resolves automatically, the request waits and each resume re-checks; a kill mid-clone leaves code.tmp kept and uncertain, never deleted; the prepareExisting/listProjects wiring is out of scope; admission revision stays null. Logs /mnt/storage/scratch/tmp/fix797/. Not executed on any running instance; local commit only, no push/merge/PR/deployment.
2026-11-04T02:51:19Z | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 / Gitea #1839 lifecycle Task 2 landing | Task 2 c55849806 (Sonnet 5.5 xhigh): exact-add organization writer, journaled bootstrap steps, 3o->3p append-only migration, receipt-backed runtime admission. Review R1 (GPT-6.1 Sol): NEEDS FIXES, C1 seed write through a committed .mosaic link, C2 recursive code.tmp delete, I1 #800 freshness only at reservation, I2 missing marker admitted, I3 no host preparation/admission wiring, I4 Task 1 existing-project root/repo comparison reversed. Fix round 883003041 (Sonnet 5.5 xhigh): O_EXCL|O_NOFOLLOW publish, claimed code.tmp, per-effect fresh(), layout-based managedState, request-host.mjs wiring, corrected localPlan. Gates on 883003041: focused 79/79, kept-green 49/49, bus 1477/1477 (first run had one model-final-completion startNice flake, reviewer found no causal path), business 305/305, project+seat 267/267, stack-release 225/225, zero skips (/mnt/storage/scratch/tmp/fix797). Review R2: C1 C2 I3 I4 resolved; I1 (org write during intent step Git reads still lets a stale intent write), I2 (failed final marker rename or later marker removal leaves admission ready) and new N1 (preexisting <dataRoot>/working ancestor link sends the working repo outside dataRoot) open. Lead decision: land under the MVP one-round rule; all three need write access to dataRoot, a concurrent organization edit or an I/O fault, and none is reachable by a sandboxed worker. They form one hardening follow-up that must land before bootstrap is exercised on .53. Minors are tracker #801.
2026-11-03 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7a | Added a bounded reviewed-artifact reader for committed regular blobs in trusted bare working repositories. Uses the existing bounded Git reader and safe environment, literal paths, disabled replacement refs, full commit IDs and a 1 MiB ceiling; rejects non-regular entries and checks size before reading bytes. RED: missing reader module; GREEN: artifact 9/9 and seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/. Queue next refused linked-worktree use; controller owns queue and integration. Local commit only; no delivery, broker, Console, push or deployment.
2026-11-04T03:48:47Z | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a before/after | Assigned base 6dd4f92b0; harness had no intake tools and staff.request required a task. Added intake_open/request.open and intake_propose/request.propose only for PM holding message.send, plus intake_status/request.get and intake_projects/request.projects in PM and CTO pre-task manifests, independent of tracker/checkouts. Existing broker authority is unchanged: its request reads still refuse CTO (request-access-refused); tested and retained rather than widening authority. intake_question omitted because no backing request.question verb exists. staff.request/request_staff takes exactly one of request or task_ref with roles/note/decision unchanged, PM-only message.send and chief routing preserved; original request provenance/reachability uses the existing host check without a fabricated task or opened journal. Exported the existing runner toolServer for acceptance tests against production dispatch, including MCP; no new imports or sandbox/process mounts needed (import closure 1/1). PM duty prose and the generated PM staffing prompt describe pre-task intake and staffing. New tests cover actual PM/CTO/coder/reviewer/assistant bundles for pi and claude-code, assistant -> chief -> PM relays, exact user-mode decision with null task/requirement, duplicate open, wrong roles, invalid/unreachable input, exclusive staff references and assistant coordination restrictions. RED on assigned parent: 6 pass/12 fail (dispatch not exported); after exporting only the existing dispatch seam 7 pass/11 fail for missing tools/staff intake. First GREEN attempt 17/18 exposed a test fixture treating message.receive's array as an object, corrected without weakening assertions. Existing required tests first 53/55 and full harness first 222/223 failed only outdated manifest/schema/prompt expectations introduced by this slice; original parent reruns required 55/55 and bundle 22/22 passed. A further generated staffing prompt RED 21/22 proved the stale task-only instruction before updating it. Final gates: request-tools 18/18, required command 55/55, full harness 223/223, bus 1477/1477, business 305/305, sandbox import closure 1/1, all zero failures/skips/cancellations. Full logs, baseline extraction and red evidence: /mnt/storage/scratch/tmp/JHTDYm/; every run used that TMPDIR with pipefail. Local commit only; no push, no role JSON/contract/migration/schema/config/credential changes, no runtime/bootstrap execution, no continuation/PRD/advance/recovery work. Controller owns review, integration and queue/tracker coordination; linked-worktree queue command refused as designed.
2026-11-04T04:06:34Z | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a before/after | Before: 6dd4f92b0 rejects operation keys and cannot recover a lost tracker create response. After: optional op reserves an immutable action.allowed tracker.create.prepared event, complete POST body/digest, target board and original PM/bot identity before any create-path network I/O; optional project_key and requirement_snapshot are opaque evidence and visible marker fields. Reusing an op cannot POST again, even after a complete negative scan or a restart before POST. Sync-only id-keyed full scans include closed tasks and require one marker, unchanged create fields, original board and creator; edited/missing/duplicate markers and partial/failed scans retain a deduplicated action.refused tracker.create.wait reconciliation receipt. Host-only recordRecoveredCreate validates the prepared receipt, body, creator and board bucket, atomically records the missing self snapshot/task.created once with original attribution, and has no socket route. Adapter exposes reconcileCreates and inspectProject, startup recovers; live PM retries resume labels/relations through read-before-write and current authority checks, assignment uses the existing separate verb. Legacy calls without new fields preserve their existing behavior; lifecycle callers must reuse a stable op for safe retries. Scope-only calls receive a generated op recorded in evidence. No migration, schema-pin edits, role/contract changes or runtime.mjs edits; packages/tasks/requirements.json and its consistency test unchanged. RED: new recovery suite 0/16 on parent; final regressions 0/21 on parent, expected invalid-request for op. Authority and forged-bucket checks also observed RED before their fixes. Corrections: event kind/subject guards required existing action event kinds instead of inventing task/ tracker kinds; restart fixtures needed historical launch cleanup before a successor claim; initial npm workspace invocation refused before tests and was replaced with per-package npm test. First full bus 1473/1477 exposed the added broker import outside the sandbox source closure, including three managed-launch failures; moved pure identity helpers into already-mounted broker.mjs, with parent project-runtime 3/3 and model-hire-ownership 18/18, then focused 42/42. Second bus 1476/1477 encountered unchanged row 280 sshd fixture cleanup ENOTEMPTY and hung; terminated only that run's owned host-run test and sshd, then waited for completion. Parent host-run was 25/26 with T27 failure (row 280 passed); local isolated host-run 26/26. Final GREEN: recovery 21/21, required verbs/adapter/tree/requirements 61/61, full tasks npm test 121/121, full bus npm test 1477/1477, zero failures/skips, diff check clean. Full logs and parent archive: /mnt/storage/scratch/tmp/CkjqAm/ (gate-recovery.log, gate-required.log, gate-tasks.log, gate-bus.log plus diagnostic history). Queue command refused this linked worktree, so no queue mutation; lead owns integration/queue. Task 5b catalogue validation, requirementsFor, built-in binding, task scope filtering and push product binding remain deferred as directed, pending Task 4. One local commit only; no push or live tracker/network/instance changes.
2026-11-04T03:23:23Z | Claude Sonnet 5.5 implementation sub-agent, t3/harden-project-bootstrap-executor | #802 bootstrap executor hardening after review R2 | One commit on 6dd4f92b0 for the three open R2 findings; nothing else touched (Minors #801, Tasks 3-8, roles/*.json, ROUTED, migrations out of scope; no schema change). N1: the working repository path is confined before and inside the helper. working.mjs ensure() now walks every directory between the data root and the repository with lstat, creates missing ones one at a time and refuses a link or a non-directory (working-repo-invalid); push() goes through the same ensure. The executor refuses a linked or non-directory ancestor as bootstrap-uncertain, keeps a repository that is already there on a first attempt, and on a retry adopts only a bare repository whose refs are root and main at the seed commit. I1: request-bootstrap.mjs gains guarded(effect): the freshness checks and the start of the effect run in one synchronous run with the organization lock held, after every asynchronous read the effect depends on. It wraps the clone, claim, remote removal and rename, the seed publish, add and commit, the working init/rename/fetch/ref writes (initializeWorking and project commitFile take an optional `around` that runs each write), the records init and the intent file publish. The old lock comment, which claimed more than fresh() gave, is replaced with what each form proves. I2: activate publishes the complete marker first and calls services.activate second, so a failed marker rename ends the step before the broker is touched. Readiness needs both a complete marker and a committed activate receipt at boot (pendingProject), at reload (activateManagedProject refuses bootstrap-marker-incomplete) and in admission() (project-pending), where a receipt alone used to be enough. RED/GREEN: run against 6dd4f92b0 source with the new tests copied in, 7 of 39 bus tests and 3 of 31 seat tests fail (n1 working link, n1 business-directory link, n1 foreign ref on retry, i1 intent interleaving, i2 EIO at the marker rename, i2 runtime marker fence, i2 marker removal live and after reopen; seat: both link refusals and the five-write around test); all pass now. Honest notes: five new tests also pass on the old source and are guards, not reproducers: the working-file ancestor (one bus test, one seat test), the preexisting repository on a first attempt, the kill-and-adopt retry, and the i1 seed interleaving (the old code ran fresh() directly before the publish on the default path, where origin is already removed); my first business-directory test asserted the link at the wrong path and was corrected. GREEN: brief trio 90/90 (was 79), bus 1488/1488 (was 1477), business 305/305, project+seat 271/271 (was 267), launcher+stack-release 261/261, zero skips. Limits: a git child's own running time after it starts is not covered by the lock, and the next step's check catches a change made meanwhile; between services.activate and the receipt commit there is one microtask where the broker already serves the project, and admission() fences that but tool serving is not fenced; push now refuses a symlinked working ancestor it used to follow. Logs /mnt/storage/scratch/tmp/V7Ps0O/. Not executed on any running instance; local commit only, no push/merge/PR/deployment.
2026-11-04T16:35:38Z | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 before/after | Before: fast-forwarded the assigned worktree to reviewed 52b283c8a; C1 foreign working-repo retry adoption/internal object links, C2 stale records and intent commits, C3 broker serving before the activate receipt. After: a first-attempt foreign repository gets a durable refused working receipt; recovery requires this plan's ownership claim, created exclusively inside the init directory before rename, as well as the existing ref checks. Every working-repository Git child checks for internal links and alternates; bootstrap also rechecks between its recovery reads. The existing kill-and-adopt recovery remains green. Organization holdAsync keeps the existing on-disk lock through asynchronous effects and releases it on success or failure; records initialization, intent publication/add/commit and intent rollback stay protected, while the bootstrap's exact-add mapping keeps its own transaction. The complete marker is verified and the activate receipt commits before the broker reload; runtime no longer overrides pending admission, and receipt I/O failure stays fenced until a fault-free retry. RED on 52b283c8a with final tests overlaid: 79 existing tests pass and all 16 new regressions fail as expected across request-bootstrap, project-bootstrap and working (95 total, zero skips); all five supplied reproducers were ported with corrected assertions. Final gates, all zero failures/skips/cancellations: focused 103/103 (baseline 90), bus 1499/1499 (1488), business 307/307 (305), project+seat 274/274 (271), launcher+stack-release including external-release cases 286/286 (286). Gate 5 uses node --test packages/cli/tests/launcher.test.mjs packages/cli/tests/stack-release*.test.mjs because these are CLI test files, not separate packages. Corrections: an initial gate-5 command named nonexistent package directories and ran no tests; an intermediate bus run failed the newly added per-child C1 regression, also reproduced on the base and fixed before the final green rerun. An earlier candidate gate-5 run was superseded and only its own descendant processes stopped; the final run completed. No preexisting test failure was found. Evidence: /mnt/storage/scratch/tmp/qRkZh1/ (before-1..4.log, before-5-cli.log, after-1..5.log, red-final-snapshot.log, verified-files.sha256). Provider interruption recovered from complete logs and matching source/test hashes. The queue refuses linked worktrees, so the explicit controller assignment was followed without changing the canonical queue. One local commit only; no push, live-instance action, role/contract/migration edit or residual C1/C2/C3 finding.
2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access.
2026-11-04T17:07:37Z | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Before: c098fa022 rejects the bare --proxy args line; read_old and stop/recovery cover only bus and Console, so a proxy hub cannot upgrade or roll back. After: accept only the bare flag, require all selected installed units to name one copy, refuse missing/linked/malformed/disagreeing proxy units and an installed proxy without its flag before stopping anything; stop Console, bus, proxy, wait for each, and preserve --proxy through both new and old renders. A2f starts proxy first and refuses a failed proxy restart with all three stopped; all rollback-failed cleanup includes the proxy. Header exit-code meanings and the ordinary two-unit path remain unchanged. Section 7 documents the flag and lifecycle. Nine added tests cover upgrade, explicit rollback, failed-upgrade restoration of all three unit files, A2f recovery/start failure, final stopped cleanup, no-proxy compatibility, malformed args, installed-unit refusals, and the real factory renderer re-pin/rollback. RED: focused #772 3 pass/5 fail/0 skip on the base, all five failures at args-unreadable --proxy. Corrections: first GREEN candidate 7/1/0 exposed the render stand-in using a different checkout path; a --checkout override was unsupported (6/3/0), so the stand-in now runs from the same fixture clone as setup; final focused 9/0/0. Required final gates (pass/fail/skip): node --test packages/cli/tests/stack-upgrade.test.mjs 34/0/0; node --test packages/cli/tests/case1-accept.test.mjs 53/0/0; node --test packages/cli/tests/case2-install.test.mjs 28/0/0; node --test packages/cli/tests/case2-unit.test.mjs 88/0/0; bash scripts/test-case2.sh 21/0/0. The four matching test files were found with git grep. Initial case2-install 6/22/0 lacked node_modules/yaml; initial scratch acceptance 13/8/0 lacked the installed dependency tree and passed after lockfile-only npm ci --ignore-scripts with distinct empty user/global configs and a scratch cache. The first npm setup attempt refused duplicate /dev/null config paths before installation. Every test invocation set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX. bash -n and git diff --check passed. Logs: /mnt/storage/scratch/tmp/JBjOT3/ (RED/intermediate/focused and all required gate logs, npm setup, verified-source.sha256). Queue next refused the linked worktree; canonical queue movement remains with the coordinator. Fixture systemctl/sudo and synthetic configs only; no live host/unit, real credentials, ~/.config/mosaic-*, ~/.mosaic or /srv/mosaic access. One local commit authorized; no push, merge or deployment. No remaining code limitation; live .53 acceptance is outside this assignment.
2026-11-04T04:05Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers, on 6dd4f92b0. The owner now picks the mode (new autonomous, new guided, existing project), answers the PM's questions and confirms an attachment with buttons; no copy-to-terminal step. BUS: 3p->3q append-only migration widens the request_events kind CHECK (bootstrap.completed, question.asked, question.answered, attach.selected, mode.changed, cancelled) and keeps rows, seq and guards; reduce gains question, selection-attach, mode and cancel state; PM-only request.question; owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console (the cancel verb is beyond the brief's list, needed for its Cancel rule) join request.get.console and request.projects.console in CONSOLE_VERBS (8 verbs, #human CLI guard untouched, agent intake verbs keep their names, ROUTED and roles/*.json untouched). Every write replays a same-op same-digest call as the current state, a different digest is request-operation-conflict, then revision, phase and question checks. A guided answer is an attributed human.input linked to question and request, once per op, secret-scanned, never a PRD approval and never a rewrite of the spec; the PM continuation is enqueued after commit. Attach records the pick from the authorized picker and the PM proposes exactly that project as a confirmation decision (a propose that differs is request-attachment-mismatch); nothing is mapped on the pick. A mode change takes the expected revision and mode_revision, bumps mode_revision and leaves the bound plan as evidence, so a pending autonomous approval or dispatch admission bound to the old mode_revision no longer matches at the next action. Cancel withdraws an open selection decision (pre-task decisions are blocking:false) or appends cancellation and keeps a created project and its mapping as evidence. thread.messages adds the request id to human instruction rows for the owner only. WEBUI: src/request-http.mjs routes GET /api/requests/:request, /projects, /draft and POST /answer|/attach|/mode|/cancel through consoleCall as the signed-in (provider, iss, sub); writes need the signed-in owner, a single same-origin Origin, Sec-Fetch-Site same-origin, application/json, an exact key set, a bounded body (4 KB, answers 64 KB / 8000 characters) and a live() recheck before the spawn; the draft read answers draft-unavailable (503) until Task 4 adds request.draft.console. serve.mjs and cli.mjs take requestRead/requestWrite (sso and send required, refused on the tester listener) and /api/config reports requests and requestWrites to the owner only. Page: public/requests.js mounts under the owner's latest three chat messages that opened a request (chat.js emits the mounts and calls MosaicRequests.mount after each paint), renders every bus string escaped with control and bidi characters shown as \uXXXX, answers the mode decision through the existing /api/decisions/:id/confirmation and /resolve pair, stores an answer in sessionStorage before sending it, and after a lost response reads the request first and resends the same op only on the owner's click. Nothing is retried by the page. Sign-out clears the panels and stored answers. RED->GREEN: packages/bus/tests/request-console.test.mjs (15), packages/webui/tests/request-http.test.mjs (14), packages/webui/tests/request-browser.test.mjs (12, real Chromium; browser.test.mjs passes 1/1 so the host browser limit did not apply). Existing pins updated as required consequences: 3q schema pin in 13 bus test files, console-decide N4 verb list (2 -> 8), browser.test.mjs script list (+/requests.js). GREEN: webui npm test 538/538, bus npm test 1492/1492 (the first full run had one failure, model-final-completion 656 FIFO startNice 16 !== 19, the same niceness flake recorded for Task 2; the file passed 21/21 alone and the full rerun was green), request-intake plus request-bootstrap 70/70, zero skips. Limits: until Task 6 the PM continuation after an answer is a plain INFO message; the draft PRD view has no panel until Task 4; the owner's attach pick is lost if the PM later appends a different setup wait; mode change is refused during bootstrap; a cancel queues behind an in-flight bootstrap; an open non-selection decision on a cancelled request is not withdrawn; a session being cleared or retired makes the answer fail closed; request_project_events kinds remain unwritten; 3q may collide with the parallel #802 migration number. Logs /mnt/storage/scratch/tmp/0pihwW/. Not executed on any running instance or Console; local commit only, no push/merge/PR/deployment.
2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Before: Task 3 sat on 6dd4f92b0 with its own 3p->3q migration and its own request-level request.mode.console; origin/refactor had moved to c098fa022, whose Task 4 core already shipped 3p-3q.sql (prd.submitted, prd.activated, prd.invalidated, mode.changed) and a project-level modeConsole. After: the two reviewed commits (7fd0c3562, c52218c65, still reachable) are replayed on c098fa022 as e9a12beb1 and e6837f76a, plus one webui reconciliation commit. MIGRATION: Task 4's 3p-3q.sql is untouched and byte-identical to c098fa022. Task 3's step is 3q-3r.sql and SCHEMA_VERSION is 3r. 3q-3r rebuilds request_events with the 15-kind union (opened, selection.proposed, selection.staged, selection.confirmed, setup.wait, bootstrap.stale, bootstrap.completed, prd.submitted, prd.activated, prd.invalidated, question.asked, question.answered, attach.selected, mode.changed, cancelled), so every kind Task 4 added is kept and the three Task 3 kinds are added; rows, seq, sqlite_sequence, the index and the six guard triggers carry over. schema.sql is the same union and SCHEMA_SHA256 was recomputed (4dfc55b4...2092). The 3b..3r chain replays to the fresh schema digest (comment-migration, schema and request-console migration tests; migrate-schema prints bus schema 3r ready). MODE: Task 3's request-level mode path (reducer branch, lifecycle changeMode, broker recordMode, MODES and MODE_PHASES, its two request-console tests and the C1 mode edge test) is removed. request.mode.console is Task 4's modeConsole: it moves the project's mode revision and every attached request, returns each to discovery with wait request-mode-changed, and refuses a stale mode revision as request-mode-revision-conflict. It sends no human.input or PM message, so it has no messaging edge to check; C1 still covers answer, attach and cancel. WEBUI: the mode route keeps its wire shape; request-mode-stale and request-mode-unchanged (codes no bus emits) became request-mode-revision-conflict (409), the mode copy and the request-mode-changed wait label describe Task 4's behaviour, and the draft route now relays Task 4's { candidate, prd, requirements } reply (the candidate must name the request) with prd-candidate-required as 409. CORRECTIONS to the 04:05Z entry above: it says 3p->3q, 8 console verbs and a request.mode.console that bumps mode_revision without leaving discovery; on this base the migration is 3q->3r, CONSOLE_VERBS has 10 entries and mode is Task 4's. TESTS: bus 1536/1536, webui 540/540 (one earlier whole-directory run failed decide-sso on a browser EPIPE and passed alone and on rerun), targeted request-console, request-intake and request-bootstrap 108/108, request-http and request-browser 28/28, prd-activation 21/21, project prd 6/6; zero skips. Not pushed.
### 2026-11-04 — Lifecycle Task 5b: scoped requirements and Git admission (#797, #1839)
Before: Task 5a create recovery was already on `origin/refactor` at `103168614`.
Task 4 (`76207fe48`, still under review) was cherry-picked as
`1627e24d79da00a5a6c393fd0b15cea4362bdb1d`; both append-only log sides were kept.
Task creation still used a private Stack requirement ID set, and Git admission did
not bind a task to its product. The queue command refused this linked worktree;
this session followed the owner's explicit Task 5b implementation assignment.
After: broker discovery and host create admission use Task 4's catalogue, with
checks during planning and immediately before admission. Product creates require
request, requirement, project key, exact approved snapshot and stable op, resolve
the board from the confirmed request, and refresh tracker membership/installation
and role/session authority. Creation evidence retains TaskScope, PRD version and
records/spec commits, including recovered creates under original attribution.
Filters/trails distinguish colliding requirement IDs. Explicit host startup binds
the baked catalogue only to the committed Stack identity with foundation ancestry
and exact repository PRD/catalogue bytes; derived products cannot borrow it. Legacy
unscoped discovery keeps its response, while intake never falls back. Host injection
preserves Broker's sandbox import closure. Git socket/process/runtime pass the code
project before bundle bytes; push recording, review and export recheck the binding.
PM tool schemas carry the scope fields. No migration; scope uses existing JSON
evidence. `requirements.json` and its consistency test are unchanged.
Verification: new scope/recovery/Git tests **35/35**; required compatibility command
**107/107**; `npm test` in tasks **132/132**, in bus **1501/1501**; additional CLI
configuration/PM tool tests **32/32**. Every final run has zero skips, failures or
cancellations, using a fresh `TMPDIR` under `/mnt/storage/scratch/tmp/XXXXXX`.
Logs: `/mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log`.
Red tests caught missing scope validation, stale tree admission, derived-product
builtin binding and missing PM schema fields. A preliminary full bus run exposed
an import-closure regression, tester refusal ordering and three managed-launch
timeouts; host injection/refusal ordering fixes passed the targeted 35-test run
and the final full suite. A subsequent full run stalled in the existing scratch
SSH fixture; only its owned test child and scratch daemon were terminated, the
interrupted evidence was kept at `/mnt/storage/scratch/tmp/task5b-bus-interrupted.log`,
and the isolated full rerun passed. All started runs were joined before completion.
Local commit only, no push, no live instance or credential-file access.
## 2026-11-04 — #797 / #1839 Task 4 Console slice — before
On `t3/lifecycle-task-4-ui` from b5a58b48b. The lead authorized the missing
owner-only draft snapshot counter after the initial UI investigation stopped:
add `{id, revision}` from the project's snapshot events, without a migration or
new verb. Build the exact PRD/requirements panel, existing decision confirmation
and guarded owner revoke route. Keep bus edits in draftConsole; leave broker,
runtime, roles and harness untouched. Verify targeted request/activation tests
and whole webui/bus suites in fresh scratch TMPDIRs; commit locally, never push.
## 2026-11-04 — #797 / #1839 Task 4 Console slice — after
The owner request panel now shows the exact current product PRD version, full
PRD and requirements, selected record paths/blob IDs, records/spec commits and
mode revision. Guided approval reuses the existing one-use decision flow and
names that candidate; changed or refused material drops its controls. The owner
can explicitly revoke an approved snapshot with a bounded reason. The HTTP route
binds project/snapshot to the URL's request and sends the snapshot-event counter,
never request revision. Refusals stay refusals, uncertain receipts stay unknown,
and neither is retried. Sign-out purges material and ignores late reads/writes.
Mode-change receipts immediately clear invalidated material.
The lead-approved bus adjustment is confined to draftConsole in
request-lifecycle.mjs: retain current owner authorization/freshness checks and
add snapshot {id, revision} from the request's active snapshot and the project's
latest snapshotEvents revision. No candidate returns null candidate/PRD and an
empty requirement list, so the counter remains readable after revocation. No
broker, runtime, requirement-catalog, migration, role or harness file changed.
Final gates (all zero failures, cancellations and skips): whole bus **1540/1540**
(bus.log); whole webui **554/554** (webui-verified.log, test-concurrency=4);
request-http + request-browser **42/42** (targeted-verified.log). Each run used
its own mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX. Dependencies were
linked without installation: root node_modules symlink; webui node_modules a
real directory with four per-entry symlinks to the canonical checkout.
Evidence directory: /mnt/storage/scratch/tmp/6YKcQI/. Earlier green runs:
activation + HTTP 42/42 (green-http-bus.log), browser 19/19 (browser-first.log),
requests 41/41 (targeted-webui.log), first whole webui 553/553 (webui.log),
requests 42/42 after invalidation (targeted-webui-final.log), and isolated
send + tester-CLI 14/14 (refusal-timeout-check.log). Expected red runs:
activation 21/22 (red-bus.log), HTTP 14/20 (red-http.log), requests 26/39
(red-webui.log), invalidation 0/1 (red-invalidation.log), uncertain receipt
0/1 (red-unknown.log). Every red run also had zero skips.
One intermediate whole-webui run (webui-final.log) had 552 passing out of 554,
one failure and one cancellation: send.test.mjs's "without --send the route is
read-only like every other write, and config says no send" got provider-unavailable
503 at sign-in; tester-cli.test.mjs's private-Unix-socket CLI test timed out at
30 seconds. Host load was above 52 on 16 CPUs. Both files passed 14/14 isolated;
the full rerun at four concurrent files passed 554/554 without changing any wait
or timeout. The same 15-second __stubReady browser waits remain. All started
runs were joined. Queue remains with the lead after the linked-worktree refusal.
Local commit only; no push or live instance/credential-file access.
2026-11-04T17:21:41Z | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 lifecycle Task 6b before/after | Before: origin/refactor 103168614 includes Task 6a intake/staff tools and Task 5a create recovery; Task 4 source 76207fe48 cherry-picked as e41ec8159d23d52963d869abf22fc9d430831f44 with both append-only log sides retained. No checked task/review advancement or durable role continuation recovery existed. After: PM-only submit_prd and advance_request map to exact PRD submission and evidence-derived planning → implementation → review → delivery → complete transitions (changes return to implementation); task-create/assignment, latest project push/review/verdict and closure evidence are rechecked under the request revision and current snapshot. Eligible PM/CTO intake_status path reads expose only selected committed draft documents. Durable continuation intents are keyed by request/revision/target/step and composed atomically with outgoing messages; immutable attempt, manifest and exact launch-refusal receipts deduplicate retries. Message reads never acknowledge progress; validated lifecycle transitions and the chief's delivery effect in the originating assistant session do. Startup/event recovery plus a single-flight 30-second pass bounded to 64 requests catches missed wakeups. Successors receive the same unfinished key, with existing routed/effect receipts rechecked before resending. The launcher starts only the configured chief through its existing launchPm path; workers use that chief's real launch/end authority. Old product manifests drain their current turn, then the chief relaunches with request.admission and real ensureCheckouts. Exact refusals appear as RequestState.launch_refusal for intake-panel integration; no Console panel is present in this base. No synthetic capability, PM launch grant, hire through the empty chief hireable list, roles/*.json change, extra scheduler service, live-instance action, configuration/credential mount or new worker import was added. Six role duty documents updated. Migration 3q-3r is necessary solely to add the checked advanced lifecycle event to the closed request_events kind list; it preserves rows, sequence and all append-only guards, with historical fixture/version pins updated. Corrections: migration digest/pins and one bundle tool-list assertion were updated; notification scheduling was narrowed so reads do not cause unsolicited bootstrap retries; an accidental repository-root npm test invocation reported no test script and was corrected to packages/harness. One broad bus run hit the pre-existing #656 FIFO child niceness 16 vs 19 assertion (also recorded by Task 4); that test passed 21/21 in isolation and the final full bus rerun passed without a source change to it. Final GREEN, zero skips: new 43/43, focused compatibility 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36; extra sandbox import closure 11/11, git diff --check clean. Logs: /mnt/storage/scratch/tmp/task6b-final-new.log, task6b-final-focused.log, task6b-final-harness.log, task6b-final-bus.log, task6b-final-business.log, task6b-final-launcher.log and task6b-final-sandbox.log in the same directory; earlier failure/isolated logs retained. All test invocations used fresh TMPDIR directories from /mnt/storage/scratch/tmp/XXXXXX. Queue command refused this linked worktree, so no queue movement was attempted in the canonical checkout. One local implementation commit only; no push, merge, release, deployment or live-instance changes.
2026-11-04T17:57:52Z | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase before/after | Before: local 347791b55 on the superseded Task 4 cherry-pick e41ec8159; origin/refactor is b5a58b48b4c254f166135c24e22bd63ce4b5e0b9 with Task 4 c098fa022, #772 793f3b157, Task 3 and Task 5b. After: replay only 347791b55 on the explicitly requested new branch t3/lifecycle-task-6b-r2; e41ec8159 is dropped. Shipped Task 3 migration 3q-3r and all seventeen existing migration files are byte-identical to the base. New 3r-3s rebuild preserves all fifteen shipped event kinds (including question.asked, question.answered, attach.selected and every Task 4 PRD/mode kind) and adds advanced, for sixteen; schema.sql matches, SCHEMA_VERSION=3s, SCHEMA_SHA256=fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03. The migration test carries rows of every prior kind, preserves a sqlite_sequence high-water mark, index and every request/effect guard, then appends advanced at the next sequence. Current-version, offline-chain and synthetic future-step test pins updated through 3s (future fixture 3t). Conflicts resolved by keeping both behavior sets: broker retains Console owner/verb guards, request question/answer/attach/cancel handlers, Task 5b builtin binding and scoped requirements/git admission, plus 6b advance and continuation services; lifecycle exports the union of Task 3 and 6b operations; runtime retains Task 5b builtin binding and #772 pending-product readiness while adding 6b recovery; harness keeps Task 5b project/snapshot/op schemas and catalog prose while adding 6b tools, with duplicate schema properties removed; six role duty documents merged cleanly and retained; append-only logs keep both sides. Integration corrections: unanswered owner questions suspend new continuation dispatch, answer evidence resumes it, and planning continuations carry the stable create op required by Task 5b. A real Task 5b adapter test creates/assigns the task with that op, advances it and keeps cross-project git refused. First full bus run 1554/1555 found a Task 3 global-zero-effects assertion; it now asserts attach preserves the exact preexisting continuation receipts. Another bus run and isolated #656 control reproduced the existing nice 16 versus 19 race: the installed Node scheduling rule changes startup priority. The owned FIFO child now sets priority after fixture construction immediately before the measured operation; all priority/nonblocking assertions remain intact, no daemon or live instance changed. FIFO 21/21 and final full bus 1555/1555 pass. First WebUI run 537/540, two failures and one skip: missing local esbuild 0.28.2 and one browser refresh timeout; npm ci --ignore-scripts installed only the locked WebUI dependencies, the browser file passes 2/2 in isolation, and whole WebUI passes 540/540 with MOSAIC_REQUIRE_EDITOR_BUILD=1 and test-concurrency=4. An accidental root npm test invocation reported no script and ran no tests; corrected to the bus package. Final GREEN, zero skips/cancellations: new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540. Every test invocation used its own mktemp -d /mnt/storage/scratch/tmp/XXXXXX TMPDIR. Logs /mnt/storage/scratch/tmp/task6b-r2-new-final.log, task6b-r2-bus-final.log, task6b-r2-harness.log, task6b-r2-business.log, task6b-r2-launch.log, task6b-r2-webui-final.log; complete per-run counts in task6b-r2-runs.json and schema evidence in task6b-r2-schema-proof.json in the same directory. No unresolved conflict, role JSON authority change, shipped migration edit, credential/config mount, push, merge, deployment or live-instance action. Queue command again refused this linked worktree; canonical coordination is unchanged. Local rebase only, with no attribution trailers.
2026-11-04 | Codex implementation sub-agent | #803 Console Model proxy in Settings | Before: the owner's native Model proxy page was linked above the Console tree at /proxy. After: Settings links to /settings/model-proxy, whose page links back to Settings; /proxy redirects there only after the existing sign-in, granted-user and named-host fences. The proxy script, fixed API routes, human bus verbs, read and write authority, origin, Fetch Metadata, transport and bounded body checks are unchanged. No secret values are displayed. Tests first: expected red 3 failures (new Settings link, redirect and tester fence), 18 passes, zero skips; final targeted proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero failures, cancellations or skips. Each test command used a fresh TMPDIR under /mnt/storage/scratch/tmp/XXXXXX; logs /mnt/storage/scratch/tmp/console803-logs/{red,proxy-page,proxy-browser,views,webui}.log. Root dependencies were symlinked and webui dependencies linked per entry without installation. Queue next in this worktree refused the linked checkout; canonical queue next t3 returned nothing, with no queue movement. Local commit only; no push, live configuration or secrets access.
2026-11-04T18:34:22Z | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question before/after | Before: base 647626bdd exposes request.question on the bus but no PM harness tool. After: PM with message.send gets intake_question in both harnesses, forwarding only request, revision, op, text and optional bounded choices; the existing broker enforces exact shape, distinct choice keys, authority and replay. A stable caller-supplied op and exact retry use the broker journal, while bus refusals remain errors and lost/unknown outcomes are reported as unknown with no automatic retry. PM duty prose describes status reconciliation and that answers do not approve a PRD. Red evidence: request-tools red-real.log 19/25, with missing tool and unknown-outcome failures; earlier fixture corrections are retained in the logs. Final gates: harness 234/234, bus 1556/1556, business 307/307, all zero failures/skips/cancellations; tests include both harness manifests, unauthorized roles, invalid input, refusal propagation, unknown outcome, stable-op replay and owner Console answer through the real bus. Logs: /mnt/storage/scratch/tmp/W6Jjos/; each invocation used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and pipefail. No bus, role JSON, contract or migration change; local commit only, no push. Canonical queue next t3 returned nothing; coordination stays with the controller.
2026-11-04 | Codex implementation sub-agent, t3/release-811-migration-reader | Tracker #811 before/after | Before: on base 647626bdd1dbfe0fd5eebd1b0e717a2da8d2a644, the release admission and plan text readers omitted the inline 3p-to-3q and 3r-to-3s pairs, leaving 15 of the engine's 17 starting versions; the .53 Window C plan predicted store-newer for a 3r store. After: both readers recognize separate-line and inline pairs, and malformed key syntax is refused rather than silently skipped; the existing plan refusal for incomplete pairs remains. The regression imports the engine's private migration array through an in-memory test-only export, compares all 17 pairs and their order, exercises mixed layouts and malformed input, and failed on the unmodified script before passing. No store or migration changed. A full release run initially had 17 rehearsal failures because the requested base's rehearsal fixture omitted Task 5b's config imports; the test fixture now copies those dependencies, the affected rehearsal sweep passes, and the full release rerun passes. Final gates, zero skips: release 226/226, external release 25/25, stack-upgrade 34/34, focused rehearsal 21/21; syntax and diff checks passed. Each test run used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and fixture services, without Docker or live hosts. Logs: /mnt/storage/scratch/tmp/bqRed6/ (red.log, focused-final.log, stack-release.pre-fixture.log, rehearsal-group.log, stack-release.test.mjs.log, stack-release-external.test.mjs.log, stack-upgrade.test.mjs.log, static.log); interrupted preliminary runs also retained there. Canonical queue next for codex returned nothing; the linked worktree itself refused queue access. Local commit only, no push or live-instance action.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b WIP | Implemented reviewed-delivery draft, 6b completion transition, owner attachment and legacy completion handling on t3/lifecycle-task-7b; no migration. Red-first logs and focused evidence under /mnt/storage/scratch/tmp/vWMFoL/. Brief trio 22/22, harness 228/228, business 307/307, seat 239/239, webui 558/558, zero skips on those runs. Whole bus 1566/1569: channel-proof freezes the changed webui transport bytes and two Console whitelist pins omit the mandated artifact verb. Each failing file reproduced alone and passed on 647626bdd. Stopped for lead ruling on the security proof baseline; tests and changes remain uncommitted, no push.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b | Lead authorized the Console transport proof re-pin after the earlier stop: webui bus.mjs changes only the artifact response cap to 2 MiB, all other Console responses retain 1 MiB, and the focused transport test checks both boundaries and refusal above them. Added owner-only artifact read, reviewed blob and reviewer record.write receipt binding, atomic completion and chief intent, same-artifact duplicate handling, historical complete preservation, safe attachment and evidence panel. Real task.close through the tasks adapter and fake tracker now feeds an end-to-end delivery fixture; coder push_working and reviewer fetch_review use distinct local clones. The downloaded HTML was opened in offline Chromium and matched the reviewed blob and hash. No migration; schema remains 3s. GREEN zero skips: brief 24, bus 1570, harness 228, business 307, seat 239, webui 559; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Previous bus failure and stop remain recorded above; no push or deployment.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, before | On 1904c9775, review found delivery could combine the planned reviewer closure with a newer unclosed second reviewer verdict. Added two real task.close regressions: unclosed V2 must refuse without completion or chief intent, and closed planned V1 must remain deliverable after another reviewer’s V2. Both reproduced the defect before the fix; red log /mnt/storage/scratch/tmp/cLYQkO/red.log. Queue next refused linked-worktree use; canonical checkout untouched.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, after | Bound delivery to the exact push, planned review, approving verdict and task.closed receipt accepted by request progress. A newer unclosed second-reviewer V2 now refuses without completion or chief intent; the closed planned-reviewer V1 still delivers. Both real-adapter regressions red before and green after (logs /mnt/storage/scratch/tmp/cLYQkO/red.log and green.log). Required gates zero skips: bus 1572/1572, harness 228/228, seat 239/239, webui 559/559; logs /mnt/storage/scratch/tmp/cLYQkO/. The first whole webui attempt had one timeout in decide-browser I1 (558/559); that file passed 9/9 alone on both this branch and 647626bdd, and the whole rerun passed. No proof pin, Console whitelist, transport, policy or schema change; no push.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8, before | Isolated branch t3/request-lifecycle-task-8-76f8d365 at exact origin/refactor base 76f8d3654faa8f22a96c1857c01c764e9637b1bd. Build the repository-only three-path acceptance harness and process crash recovery with shipped authority, persistent fake external services, real typed tools/Git/Console, plus a guarded explicit-instance live runner. Task 7b complete/delivery/artifact behavior is authoritative. No policy expansion, shipped migration edits, canonical working-tree writes, installs, credentials/config reads, push or live acceptance. Queue next refused linked-worktree access; coordination remains with the lead. Root node_modules linked to canonical dependencies; webui node_modules is a real directory with per-entry symlinks. Gates use fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX and retained logs. Original fixture provenance is an owner-supplied incident quotation, not a runtime row or live receipt.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8, stopped on brief/base conflict | Task 8 requires attach_existing_and_revise_missing_requirement to attach to an authorized existing product, reuse a matching approved snapshot or revise when missing, and deliver without creating another project. At exact base 76f8d365 the shipped hostPreparation in packages/bus/src/request-host.mjs supplies only preparePlans/admitPlan; neither it nor runtime/broker supplies listProjects or prepareExisting. request-lifecycle projects() therefore returns [] for every production-host picker; attachment refuses request-project-refused. The existing-path unit fixtures inject both missing services. Even with those test-only injections, selection.confirmed explicitly leaves existing associations in discovery/request-attachment-unavailable; runBootstrap skips existing bindings, and no shipped transition adopts the existing approved snapshot. Reproduction: /mnt/storage/scratch/tmp/fayxaI/attach-contract.test.mjs starts the real file-backed runtime with shipped role definitions and scopedFixture (only SCOPES imported from world, no permissive AUTHORITY/world broker), bootstraps timer through actual owner selection and product PRD approval, proves its snapshot resolves, opens a second request and resolves existing through authenticated Console verbs. Expected picker includes timer; actual []. Retained log /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log: 0 pass, 1 assertion failure, 0 skips/cancellations; fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX with pipefail. This is a deterministic missing host capability, not a load failure. Per explicit assignment rule, no implementation chosen or production behavior changed; remaining harness/live-runner work and broad gates not run, no commit because acceptance is not green. Lead resolution needed: authorize Task 8 to implement the missing existing-product host preparation/attachment/snapshot-adoption path, or provide a revised base/brief that supplies it. No roles, ROUTED, security boundary, shipped migration, canonical tree, real credential/config or live host touched.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8a, after | Lead ruled the missing existing-project host path an in-scope gap at 76f8d365, superseding the stop without changing its record. Wired the shipped host inventory/preparation to current owner/business mapping, project config, admitted product registry and existing role/tracker/mode authority. Picker rows carry the organization mapping revision; only the server retains the displayed inventory, and the owner pick journals that revision. A host restart before picking requires a fresh read. The exact current mapping, source commit, existing paths, tracker, staff and mode form the confirmation; stale picker, pick or confirmation refuses. Attachment holds the organization lock and processing lease through validation and its atomic bootstrap-kind receipt, creating no resource. A validated active product snapshot with the same exact original intent is a conservative proven fit and is adopted without a new approval; other intent enters PM discovery/revision, and the new attached request cannot borrow the older active snapshot for task creation. This adds bounded request binding under existing authority, no role, action, capability, ROUTED, Console whitelist, transport proof or security boundary change. Existing bootstrap.completed and attach.selected payloads gain optional host-validated attachment fields; historical rows are unchanged, no new event kind/table or migration, schema remains 3s. Retry after temporary records-read refusal now resumes instead of getting stuck in an invalid transition. README documents freshness and the conservative fit rule. Red logs: 8a-red.log (6/6 fail), 8a-red-scope-http.log (borrowed snapshot accepted; HTTP fixture initially missed log callback), corrected 8a-red-http.log (revision absent), 8a-red-retry.log (working-file dirt was not a valid committed-read refusal), corrected 8a-red-retry-real.log (repair could not resume). Original retained diagnostic went red-to-green; focused compatibility 105/105, final new tests 8/8. Final required gates, zero failures/cancellations/skips: bus 1580/1580; harness npm test 234/234; business npm test 307/307; seat npm test 239/239; whole webui node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 563/563. All commands had fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX and pipefail; logs /mnt/storage/scratch/tmp/UMYC56/8a-final-{bus,harness,business,seat,webui}.log, 8a-green-final.log, 8a-static.log. Earlier green runs retained separately. Changed-module syntax, relative imports, documentation/reference review and git diff --check pass. Root dependency link and real webui directory of per-entry links only, no install. Pending before/stop SESSIONS and BUILD-LOG lines included as instructed. Local Task 8a commit only; Task 8b and lead-only live window remain.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8b, before/after | Before: Task 8a local 81e54bf4d82f670c5c2aba947e650bc5d74a90f2 supplies the missing host path, but there was no three-path process acceptance fixture, standalone offline timer acceptance or guarded live runner. After: added the original owner-supplied quotation with its unchanged source IDs/SHA/provenance and an explicit distinction from newly generated runtime input/message IDs; real file-backed startBroker in a killable process; persistent parent FakeVikunja HTTP service with scoped fake credentials and valid isolated organization; shipped role definitions, real chief launch authority/limits, resolver/bundles/session manifests, independently spawned typed-tool callers, real record/PRD/task validation, Git socket/bundles, separate coder/reviewer workspaces and run identities, independent committed Node timer test execution, and signed-in fake-OP Console HTTP/browser actions. Scripted model responses supply agent-proposed 25/5 durations, start/pause/reset and a gesture-enabled cue only through the approved product PRD. The coder writes actual self-contained HTML, with request/snapshot provenance metadata; the reviewer fetches that exact commit, runs its test and writes the verification record before its verdict. Real close/deliver and chief → assistant → user messages complete the reviewed local delivery; no merge, remote publish or deployment is implied. Autonomous original-wording, guided question/reject/revise/exact approval, existing matching snapshot and missing-requirement revision, cancellation and mode change during approval all pass. Eight hard process crashes (selection resolution, mapping, intent/spec commit, PRD resolution, tracker POST, working push, review verdict, export before notification) restart over the same on-disk Store/repositories and surviving fake service; tests require unchanged historical rows, identical known IDs/commits and one product/task/artifact. A hidden remote POST outcome stays uncertain without a replacement POST or delivery claim and continues once proof is readable. Chromium controls time and verifies work/break transitions, pause/resume/reset, cue after a user gesture and no external requests while offline. Fixture success is integration/recovery evidence, not live model quality or deployment readiness.
Task 8b defects reproduced before fixes: owner answer/attach/cancel controls on assistant-origin requests tried to thread a human→PM message into the assistant session, correctly refused by SQLite; controls now use the PM inbox unless the origin session is itself PM-owned, leaving the original request/session binding and message graph intact. Cancellation in PRD review previously selected the closed bootstrap decision; it now withdraws only the request's exact current pending PRD decision, with existing owner, PM, project and messaging checks. Targeted production regressions 2/2 red then green (8b-red-controls.log, 8b-green-controls.log). No schema/migration, shipped role JSON, ROUTED, capability, Console whitelist, transport-proof or security-boundary change. Existing bootstrap.completed/attach.selected extension from 8a remains schema 3s. New live-runner preflight tests 2/2 red then green. Repository live runner requires an explicit named isolated development instance, matching marker/config, expected business owner, existing staff/source/tracker/host, owner process proof and explicit run authorization before submission; it never starts hosts, supplies mode/PRD approval, provisions accounts/boards/grants, retries an unknown submission, or defaults to .53/.54/production. It retains request revisions and review/task/download receipts; exact artifact hash/length are checked separately from live browser acceptance.
Task 8b verification corrections: early harness runs exposed fixture cleanup ordering, unchanged record writes, globally reused create ops, missing persisted launch proof fields, a reused commit with nothing to push, a cross-request timestamp assertion and a missing empty owner-audit file. Those were corrected with the same strict production APIs; logs 8b-autonomous-*.log, 8b-integration-{1,2,4}.log and 8b-paths-recovery-3.log remain. The first autonomous prototype run was interrupted after a cleanup hang; it is recorded as a cancellation, not a passing run. Final required two-file acceptance run 16/16, zero skips/cancellations (8b-integration-final.log). Full CLI first run: 1192 tests, 1139 pass, 52 fail, 1 skip (8b-cli.log). Every failing file reproduced alone: lifecycle, proxy-launch-order, session-clear, thread-launch and thread-revoke (isolated-*.test.mjs.log). Their trusted-host fixtures lacked the request-host operations already used by the landed launcher. A shared test-only forwarding table now calls the actual runtime methods, with no unconditional allow/ready result; all 60 affected tests pass (8b-launcher-fixture-green.log, 8b-thread-fixture-green.log). The ordinary CLI suite intentionally skips R7; the final full npm run enabled R7 under an isolated fake HOME and its existing group/port/socket cleanup wrapper, adapted outside the repo only to run the complete suite. No federation/proof policy or production script changed. Final CLI 1192/1192, zero skips, with R7 acceptance and clean child/listener cleanup (8b-cli-final.log).
Task 8b final whole-package gates, all zero failures/cancellations/skips: bus 1582; CLI 1192; tasks 132; project 50; harness npm test 234; business npm test 307; seat npm test 239; whole WebUI node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 564. Total 4300. Every test/gate invocation used fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX, pipefail and retained logs; no install. Root node_modules remains a link; WebUI node_modules remains a real directory of per-entry links. Gate matrix /mnt/storage/scratch/tmp/tupyBK/8b-gate-results.json; 16 case-labeled immutable evidence sets indexed by /mnt/storage/scratch/tmp/tupyBK/8b-acceptance-evidence-index.json, including artifact bytes, original quote, live fixture IDs, histories, manifests, coder/reviewer test logs and crash-lock proof. Syntax, import/Markdown references, whitespace and byte preservation of shipped authority/migrations passed (8b-static-final.log). Self-review performed; no sub-agent/delegate was started. Queue coordination remains with the lead after the linked-worktree refusal. Local second commit only; no push or canonical working-tree/live-instance/real-credential action.
Lead-only live window checks, not executed here: (1) explicitly authorize and select a named isolated development instance on .53 with its own data root/marker, installed source/board, scoped credentials, real PM/CTO/chief/coder/reviewer/assistant routes and signed-in owner Console; retain preflight and refuse any missing resource rather than provision it. (2) Run packages/cli/live/request-lifecycle.mjs with explicit --instance, --config, --business, --owner, --request-file and --evidence plus --authorize-isolated-run, from a genuine owner shell so the existing process proof applies; use --submit with an existing owned --thread, or observe the owner's existing --request. The owner selects/confirms the exact displayed autonomous plan once in Console. No user relay, manual task creation or worker shell command is part of the lifecycle. (3) Retain original request/message bytes, exact plan confirmation, approved snapshot/decision, tracker create/assignment, working commit, independent reviewer fetch/test/record/verdict, matching closure, delivery and chief/assistant notification; check identical project/snapshot/task/commit/review/verdict IDs across the chain. (4) Download through the owner-only Console URL, rerun observation with --request and --artifact, and require byte length/SHA to equal the reviewed delivery. Open that downloaded HTML with network disabled and verify the approved durations, start, pause/resume/reset, both transitions and the phase-end cue after Start, with no external requests/assets. Inspect actual independent model/session/test receipts before declaring live acceptance. (5) If live crash checks are authorized, kill only the owned isolated broker at the eight named checkpoints; preserve DB/WAL/SHM and repositories. Follow the existing operator rule: establish the exact writer PID is dead, retain its lock identity, and explicitly remove only the stale lock/socket before supported restart. There is no production automatic stale-lock reclaim and this task does not add one. Require unchanged historical rows, identical known IDs/commits and no duplicate project/task/artifact; an unprovable remote outcome must remain a visible wait. Full invocation and qualifications are in packages/cli/live/README.md. Stage 4 live acceptance remains with the lead.
2026-11-04 | Lead Codex, #815 / Gitea #1843 chat-channel recovery before | Owner /chat screenshots show failed read plus false empty state and Create project channel-disabled. Read-only .53 checks: plugins.channel absent; existing usage-source transcript and harness claude-code retained; Console journal has matching refusal; existing typed tester plugins API is authorized. Failure precedes project dispatch. Plan: typed tester channel-only correction with private config backup/readback and no restart; Codex source fix for factory channel declaration and accurate failure UI, tests/review before integration. No signed-owner workflow or schema/role/secret change.
2026-11-04 | Lead Codex, #815 / Gitea #1843 live configuration outcome | Operator enabled cli+webui through pinned 51525a31d tester plugin setter at 22:43:43Z, transaction e94c4db7-c3e3-4d31-825f-0b204a1ccf76; committed actor tester, only plugins.channel changed, other configuration deeply equal. Private backup/rollback report: /mnt/storage/scratch/mosaic-lead/reports/chat-channel-815.md. Lead independently verified configured channels, committed receipt and real tester WebUI GET /api/chat/projects HTTP 200. Services retained PIDs; no deployment/schema/credential/model/role change or owner workflow. Source regression fix remains active and issue open.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, before (recorded during implementation) | Started in T3-prepared branch t3/channel-disabled-chat-recovery at controller base 3572bf9b32f414283686e828155ae859a45c537e, source baseline 51525a31d. Scope: explicit Case 1 CLI/WebUI opt-in and Console channel-refusal/failed-read recovery; preserve global defaults, enforcement and no-tracker starter. Controller owns coordination and live repair; no live config, role, queue or transport changes.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, after | Case 1 explicitly opts into cli and webui; no-tracker starter and global defaults remain unchanged. Console renders a fixed disabled-channel explanation, retains in-memory drafts and distinguishes definite refusal from unknown outcome, disables known-refused creation/send actions, marks cached reads stale and does not claim empty on failed reads. TDD red logs and green gates under /mnt/storage/scratch/tmp/mosaic-815-logs/: final targeted chat/room HTTP and Chromium 100/100, full WebUI with MOSAIC_REQUIRE_EDITOR_BUILD=1 574/574, business 307/307, zero skips/cancellations. First full WebUI after root npm ci: 569 passed, 1 missing WebUI esbuild failure, 1 editor-build skip; WebUI lockfile install restored the pinned dependency. Concurrent run: 571/572 with one unrelated decide-browser I1 wait timeout; isolated decide-browser 9/9, subsequent full gate green. Superseded full gates were stopped when later regression corrections changed the source. No bus.mjs/proof pin, role, policy, auth, live host/config, queue, push or deployment change. Draft recovery tested through the Console Refresh action; full browser reload does not persist in-memory drafts.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 source fix round 1, stopped | At base 5558fe020 reproduced room channel state loss and sticky legacy-session controls in real Chromium, then made the scoped state corrections; focused red/green logs and the 47/47 covering-browser pass are under /mnt/storage/scratch/tmp/mosaic-815-r1/. The single authorized full WebUI run with MOSAIC_REQUIRE_EDITOR_BUILD=1 and concurrency 2 finished 575/576, zero skips/cancellations: unrelated thread-claims-browser row278 timed out waiting for its table; that entire file then passed 6/6 alone. The unchanged vendored-editor import warning remained, and the editor rebuild passed. No commit: the full applicable suite is not green, and the brief specified one full run. Controller owns the decision on further verification. Minor cached-room stale-label finding remains unchanged. No live, bus/proof, authentication, policy, role, schema, tracker, push, merge or PR change.
2026-11-04 | #815 / Gitea #1843 source fix round 1 controller verification | Existing authorization covered another verification run after the unchanged row278 browser timeout. Same command and test settings: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/; 576 passed, zero failures/cancellations/skips, required editor rebuild passed, duration 168128 ms. Source/test blob hashes unchanged from pre-run snapshot; no test weakening or unrelated source changes. Both earlier full failure and isolated 6/6 evidence retained. Log: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-controller-retry.log. Local commit now permitted; independent I1/I2 re-review next.
2026-11-04 | #815 / Gitea #1843 integration phase started | Independent scoped re-review APPROVE at a1ba9cd5669cf084ef69eb663b42234fbce38824: I1/I2 addressed; no new critical/important finding. Applied only the five reviewed source/test/template paths to refactor and appended worker records while preserving all existing controller log bytes. Entire tracked source outside BUILD-LOG/SESSIONS matches the reviewed candidate. Running final canonical WebUI gate and factory regression before commit/push. M1 cached-room stale label and M2 unchanged tooling/audit observations remain deferred/nonblocking. No deployment or owner acceptance.
2026-11-04 | #815 / Gitea #1843 integration phase verified | Canonical full WebUI: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/ passed 576/576, zero failures/cancellations/skips, required editor rebuild passed (171530 ms); factory regression passed 8/8, zero failures/cancellations/skips. Logs: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-canonical.log and factory-canonical.log. Entire source outside append-only logs still equals independently approved candidate a1ba9cd56. Prior full business 307/307 applies to identical business/template source. All existing controller and worker log bytes preserved. Committing and pushing the reviewed repair under the established plan; source deployment and owner PRD-to-artifact acceptance remain separate. Known in-memory full-reload draft loss, minor cached-room stale-label omission and unchanged tooling observations retained.
2026-11-04 | #816 / Gitea #1844 design phase started | Owner supplied current Chat/project/thread screenshots and familiar chat references, specifying Projects navigation, expandable project/session sidebar, customizable display, project KPI dashboard, in-project issues and north-star/PRD access, PRD agent alerts, and room broadcast. Begin design-only collaboration, then wireframes and generated images. Mobbin research follows owner connection; no application implementation, PRD/schema/policy changes or live operations. Brief: docs/plans/2026-11-04_projects-workspace-design-brief.md.
2026-11-04 | #816 / Gitea #1844 wireframe draft phase | Navigation and autonomous-conversation reports align on project tree, distinct dashboard/disclosure actions, breadcrumbs and conversational work cards. First design artifact at /mnt/storage/scratch/mosaic-lead/design/projects-816/projects-wireframe.html; previewed desktop/mobile and checked local navigation/audience interactions. Dashboard detail remains provisional while its designer finishes. No application code or live change; wireframe example data and proposed capabilities are labeled.
2026-11-04 | #816 / Gitea #1844 initial design phase delivered | Combined all three GPT reports into design-synthesis.md and reconciled v2 wireframes. Desktop/mobile preview and local state checks passed for navigation/disclosure, decisions/confirmation, local delivery, unavailable KPIs and room audience. Built-in image generation produced selected dashboard-v1.png, conversation-v2.png and room-v1.png; originals/variants and exact prompts retained, dimensions/SHA manifest recorded. Pack: /mnt/storage/scratch/mosaic-lead/design/projects-816/design-pack.md. This is a design draft for feedback; Mobbin research remains pending connection, not complete or approved implementation. Mandatory Git wrapper refused the #1844 update due to missing acting identity; prepared comment retained and Vikunja #816 updated without bypass. No application, PRD, schema, authority, credential or live change.
2026-11-04 | #816 / #797 owner full-send parallel phase started | Owner requested independent UX Lead and MVP 3 Lead sessions. Both launched in separate T3-prepared worktrees at e9271841f, read back running; UX confirmed Mobbin search. Disjoint source/artifact ownership, preserved read-only initial reference manifest, separate status files, root-only canonical integration. Design work cannot modify application source and does not block functional repairs. Current browser capture unavailable in UX lane; supplied screenshots remain usable. Repository-native worktree/storage rules take precedence over generic estate prefix; /var/src creation denied, disk-backed /mnt/storage task storage retained. No live change or acceptance claim.
2026-11-04 | #816 / #797 parallel phase evidence milestone | Independent visual baseline assessments verified against preserved references; method corrections and reviewer dissent retained without author disclosure. UX is finishing a new wireframe state package and independent review, with frozen manifest required before assessment. MVP reported an isolated source candidate with retained RED evidence and scoped gate; full-package checks and exact-candidate review remain in progress. Root made no application source, runtime, credential, deployment or acceptance change.
2026-11-04 | #818 reusable UX improvement skill phase chartered | Owner requested reusable personal/distributable skill. New isolated GPT author owns skills/ms-ux-improvement and package-focused behavioral checks; root owns independent review/integration. Existing frontend-design guidance remains intact; no mandatory subscription/provider dependencies. Candidate is not yet reviewed, installed, activated or published. Active UX and MVP lanes continue without overlapping paths.
2026-11-04 | #797 / Gitea #1839 R2 integration phase started | Exact patch18ff6455 verified, fresh R2 APPROVE and4333 distinct required local cases observed through retained normal-exit receipts; the one ordinary FEDR7 skip is covered by its dedicated standard wrapper. Apply only12 reviewed non-log paths on e9271841f, preserve/import candidate log suffixes and keep UX/skill source separate. Native Git transport currently refuses anonymous-credential-refused despite owner-confirmed Mos API identity; no helper/launcher bypass or live action authorized by this integration.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair, before | T3 isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; charter /mnt/storage/scratch/mosaic-lead/mvp3/functional-repair-plan.md. Reproduce late evidence, reviewer contract ordering, linked relay and requirement guidance before bounded fixes; preserve authorization and immutable receipts. Queue refused linked worktree; no canonical or queue access. Pinned npm ci only; no commit, forge, live or protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair, after | At e9271841f3af23bf2a72aa17d9d67a1ec5abe017 reproduced premature PM continuation stalls, missing first review JSON contract, missing linked request header, and absent typed requirement example. Added evidence-keyed durable wakeups, overlapping recovery handling, lifecycle review guidance, owner/thread/role-checked relay provenance and requirement guidance. Focused RED/GREEN, cross-owner and removed-role negatives, late-record refusal and both harness prompts retained under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/. Final bus/harness/tasks/project/business/seat and relevant CLI gate: 2610 passed, 0 failed/cancelled/skipped, concurrency 2, MOSAIC_REQUIRE_EDITOR_BUILD=1 and private /mnt/storage/scratch/mosaic-lead/mvp3/a9. Earlier long-path suite refused Unix sockets and was interrupted (2591 pass, 88 fail, 75 cancelled, 1 skip); shorter-path lifecycle and three launcher reproductions passed. Two later intermediate suites were interrupted for newly reproduced provenance/claim races; all logs retained. No commit, push, forge, canonical, configuration, role JSON, schema or live change; independent exact-candidate review and lead's broader gates remain.
2026-11-04 — MVP 3 independent lead candidate validation (#797/#1839): applying the byte-verified Terra candidate d8ef58ffc06b63e34d5e4e056898523fdb43643b933edcf3ed45135a2cc922b6 to the isolated lead worktree for broader local verification; canonical integration/live acceptance not performed. Author RED/GREEN retained; independent review pending.
2026-11-04 — MVP 3 R1 remediation (#797/#1839): lead confirmed retained-history128/257 wake race with RED tests and repaired recovery to retain a complete reinspection cycle in bounded batches after overlap. Reviewer scratch plus source regressions and quiescent control4/4GREEN. Source remains uncommitted/unintegrated. Earlier parent broad run was mixed-candidate, ended143 without root terminal summary, and is not a final gate; excludedCLI compactions/revoke retry under short authorized TMPDIR passed6/6. R2 candidate and matching full-package verification pending.
2026-11-04 | #797 / Gitea #1839 R2 canonical integration verified | All12 non-log files equal independently approved/tested source manifest6daf0668101bbad030c45411304d0d049de5e4da32bdcb03c40095ed1c63128f;8 JS syntax checks and whitespace check passed. Canonical prior log bytes preserved and candidate author/lead suffixes appended. Retained gate coverage4333 distinct cases plus independent115 focused checks applies to identical source; no duplicate broad test run. Commit/publish and deployment remain separate operations; no live acceptance claimed.
2026-11-04 | #818 / Gitea #1845 skill integration phase started | Independently approved candidate02, exact3-file manifestb93d7836 and14 package-focused checks; fresh forward-use artifacts and synthetic/single-model limits retained. Integrate only new skills/ms-ux-improvement plus append-only records. No application/source-family overlap, installation, activation or publication.
## 2026-11-05 — UX improvement skill author phase (Vikunja #818; related Gitea #1844)
Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no UX improvement skill. Scope: personally author skills/ms-ux-improvement, retain baseline/forward evidence at /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z. Queue next refused linked worktree (exit 2); direct owner charter governs without queue/forge writes. Package-focused checks only; no application suites, commit, activation or publication.
## 2026-11-05 — UX improvement skill author phase completed (Vikunja #818; related Gitea #1844)
After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions.
2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit.
## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase
Before: isolated checkout at 05fdfef9ea1fdddf58c725c04f229ca8559fc054, branch fix/mvp3-relay-797; live acceptance diagnosis identified configured-chief addressing, misleading relay receipts, bootstrap approval wording, and premature-staffing wording. Direct charter authorizes bounded implementation, tests and local commit only. Queue next refused linked worktree; no queue, forge, push or live action. F2 regression reproduced null receipt before repair; scratch evidence and final gate will be recorded in /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase completed
After: assistant addresses the configured chief instance; role-send receipts derive provenance from the stored message and existing reply-chain checks; PM and chief guidance describes the existing bound bootstrap decision and request-based staffing before task creation. No role JSON, schema, policy or alias change. F2 whole-file regression was red (7 pass, 1 fail: null relay receipt) then green (8 pass), with unlinked, wrong-recipient and caller-supplied-ID negatives; threaded reply file passed 20/20. Final whole-suite gates: bus on Node 24.22.1 1596/1596; harness 242/242; business 307/307; 37 additional broker-importer files 512/512; zero failures, cancellations or skips. Original Node 26 bus attempt was interrupted after project-files stalled in a socket send: 1593 pass, one cancelled; that whole file passed 8/8 on Node 24.22.1 and the complete bus suite was rerun. Scratch TMPDIR, exact commands and logs: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. Local commit authorized; independent review and all publication or live actions remain outside this author phase.
## 2026-11-05 — MVP 3 relay R2 trusted coordinator context (#797 / #1839)
Before: isolated branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36. Independent R1 review's sole CHANGES finding: owner-started assistants could not discover an arbitrary configured chief key, especially offline. Direct R2 charter authorizes trusted generated routing context, offline swapped-key integration evidence, required tests, append-only records and a local commit; no push or live action. Queue read refused linked worktree, then canonical read returned nothing; no queue mutation.
After: assistant-only resolved coordinator context comes from validated launch.by and the coordinator's optional configured identity.name. The generated prompt names the exact instance key and role guidance uses it explicitly; no-launch context reports a blocker. Broker source, messaging policy, recipient checks and role JSON are unchanged. Config-to-bundle-to-broker integration covers owner-launched assistant, offline chief, violet/opal definition swap, descriptive name, ignored session override, correct stored recipient/provenance, forbidden CIO and definition-alias refusals. Required whole-file gate 321/321 and additional content/path/bundle checks 124/124 on Node 24.22.1; final touched bundle file 24/24, all zero failures/cancellations/skips. Existing digest machinery covers assistant coordinator and prompt bytes; no literal changed-source hash pin required an update. Original reviewer probes 12/13 because their F1 deepEqual asserts the diagnosed defect; preserved original and a scratch copy changing only that assertion to notDeepEqual passed 13/13, including F1. Initial added fixture runs had two setup mistakes (missing store directory, thread claims absent from agents); corrected. Initial related run 79/124 had missing local dependencies and one Claude style-hook assertion; npm ci from the unchanged lockfile followed by the same whole-group rerun passed 124/124. Scratch evidence: /mnt/storage/scratch/tmp/0T6C89; exact commands and limits: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md. Local commit only; independent exact-candidate review remains.
## 2026-11-05 — MVP 3 planning coverage implementation started (#797)
Before: pinned 05fdfef9ea1fdddf58c725c04f229ca8559fc054; live b1869a13 has five approved requirements but one singly cited task. Scope: preserve scalar task schema, surface exact uncovered IDs to PM, clarify planning versus push evidence, diagnose coder stop and report D3–D5. Local branch only; no push, merge, runtime writes or role authority changes. Queue refused in linked worktree; direct implementation charter governs this child.
## 2026-11-05 — MVP 3 planning coverage implementation ready for review (#797)
After: preserve the scalar task requirement schema and fail-closed coverage gate; incomplete advance now sends a deduplicated PM bus notice naming every uncovered id and explaining correction, task-limit reconciliation and why a push cannot clear coverage. PM contract, task_create description and planning continuation state the same rule. Five-requirement regression proves prose/push do not cover missing ids, retries do not flood, changed coverage updates the notice, and complete assigned coverage advances without pushes for the other tasks. Focused whole files: 52 pass/0 fail/0 skip; scoped/lifecycle/import/role regressions: 115 pass/0 fail/0 skip; dependency/path/closure files: 61 pass/0 fail/0 skip (228 total). Initial fixture run: 51 pass/1 fail (new test used an invalid PRD author; corrected to pm.pm). Initial path checks: 28 pass/23 fail from absent worktree node_modules; canonical dependency versions matched package.json, linked locally, then all 61 passed. Read-only .53 evidence identifies proxy-stall watchdog as coder stop cause; D2–D5 remain report-only follow-ups. Detailed commands, receipts, limitations and exact commit: /mnt/storage/scratch/mosaic-lead/mvp3/planning-gate-fix-r1-report.md. No push, merge, runtime write, role authority expansion or product-acceptance claim.
## 2026-11-05 — MVP 3 coder first-activity watchdog correction (#797), implementation handoff
Before: isolated branch fix/mvp3-coder-stall-797 starts at 05fdfef9ea1fdddf58c725c04f229ca8559fc054. Claude text-mode stdout appeared only at the final answer, so the 120-second first-output watchdog could stop productive thinking/tool work (D2 in planning-gate-fix-r1-report.md). Explicit child charter authorizes a local implementation/test/commit only; queue next codex refused this linked worktree, with no workaround or queue mutation.
After: Claude's private stream-json reader marks real upstream assistant events and emits only the successful final text, matching real CLI newline semantics byte for byte. Pi's existing extension also marks model text/thinking/tool deltas; mock remains a test-only immediate echo. Both reuse the existing exclusive 0600 proxy-first-output marker with fixed output\n content; init/status, synthetic CLI errors, tool results, stderr and workspace writes do not count. Runner supplies only its existing run directory; helper gets a read-only harness mount and bundle source digest. No timeout, schema, role authority, credential, proxy management or live state changed. Silent runs still stop with proxy-stall; interrupted first/resumed turns emit no partial answer or successful new session ID.
Verification: Node v24.22.1 with PATH pinned to /mnt/storage/scratch/tmp/z62zsJ/node24/node_modules/node-linux-x64/bin and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Final full 28-file command: 376 passed, 0 failed/cancelled/skipped/todo, test-concurrency=4 (coder-stall-final-r2.tap). Includes full launcher/model-stall, harness runner/bundle, seat session/sandbox, real Claude/Pi adapter, confinement, installer/copy, source-digest and import-closure checks. Delayed tool/thinking/header/assistant fixtures use 0.8-second injected watchdog and 1.4-second pre-answer work. Initial tests retained: missing test import and failed-result exit handling, real text-mode trailing-newline difference, then a combined-gate grandchild-reaping assertion race; all corrected and full final gate green. Whitespace and baseline log-prefix preservation checks pass. Report, exact commands, SHA256 manifest, scans and all TAP receipts: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md and coder-stall-* siblings. Local commit follows these green suites. Independent exact-commit review, CI/integration and deployment/live GLM acceptance remain with the lead; no push, stash, fleet operation or external message.
## 2026-11-05 — MVP 3 coder-stall review F1 correction (#797), local implementation handoff
Before: candidate 1bd0077391f1925db5e66796b98540e48f13bfc2 discarded complete error-typed result strings, losing text-mode stdout such as API Error: 400 review-local-api-error with exit 1. Restoring that output also required preventing the runner's generic stdout fallback from mistaking synthetic errors for model activity. Explicit correction charter authorizes one local follow-up commit on fix/mvp3-coder-stall-797; the linked-worktree queue refusal was diagnosed and left intact.
After: the private stream reader renders every complete final result string independently of success/failure, retains CLI nonzero exit codes, fails error-typed zero-exit turns, and withholds result text on stream-integrity/helper marker failures. The adapter's existing success-only session persistence remains unchanged. The Claude plugin declares adapter-owned activity in its existing bundle environment; the runner reads module metadata, ignores ambient declarations and keeps stdout fallback for Pi/worker/mock paths. No core harness-name conditional, watchdog timing/default, broker, roles, contracts, policy or live state change. Bundle tests pin the helper SHA-256 to 9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b.
Verification: before product edits, whole-file negative controls on 1bd007739 passed 68 and failed 7; the real installed Claude CLI HTTP 400 test failed on empty streaming stdout versus 38 text-mode bytes. It now passes with identical stdout bytes and exit 1, no activity marker and no new session ID, using only localhost, isolated HOME/configuration and a dummy key. Added subtype/is_error/nonzero controls, corrupt-after-result/oversized/truncated/helper marker-failure cases and real runner fallback controls. Final frozen gate: 414 passed, 0 failed/skipped/cancelled/todo across 31 whole files, one node --test invocation per file, at most two concurrently. Node v24.22.1 on the requested PATH; set -o pipefail and fresh scratch TMPDIR. The initial broad gate's sole architecture failure (412/413) is retained honestly; moved the declaration into the plugin and reran all 31 files green. Whitespace, source hashes and append-only prefix checks pass. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; per-file TAPs, exact commands, negative controls, source/pin scans and hashes: coder-stall-fix-r2-evidence/. Local commit follows verification; independent exact-commit review remains with the lead. No push, stash, forge write, real model call, contact with 10.1.1.53, deployment or live recovery.
## 2026-11-05 — MVP 3 coder-stall review F2 correction (#797), local implementation handoff
Before: 8094901bce2d46f952786eea6469643d8dac8868 propagated helper death/load errors without the fixed stream diagnostic; Node exit 1 or SIGKILL status 137 could also be ordinary complete CLI error codes. Explicit correction charter authorizes one new local commit on fix/mvp3-coder-stall-797. Queue next coder refused this linked worktree; no workaround or forge/queue mutation.
After: the adapter captures a private fd-3 completion handshake independently of answer stdout, CLI stderr and exit status. The helper's child does not inherit the completion descriptor. Complete CLI results retain their bytes and nonzero codes; invalid/absent completion emits exactly the fixed diagnostic and cannot save a new session. Helper stacks and shell crash text stay private. No completion file or writable mount, no broker/role/contract changes, and no watchdog timing/default changes. Helper SHA-256 and bundle pin: ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0.
Verification: added adjacent SIGKILL, missing-helper, static-import and runtime-exception regressions plus complete CLI exit-1/137 byte-parity controls. Before product edits, whole-file baseline repeat had 20 pass/4 intended fail; the first run's additional short-watchdog assertion failure is retained honestly. Focused fixed file passes 24/24. Final requested matrix: 420 pass, 0 fail/skip/cancel/todo across 31 whole files, one sequential node --test invocation per file. Node v24.22.1, requested PATH first, set -o pipefail and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Real CLI localhost HTTP 400 retains identical 38 stdout bytes, exit 1 and empty stderr, no activity/session. Adapted direct and 13 managed hostile/fallback probes pass; managed helper death/missing-helper stderr is exact, with no activity/session and recorded descendants reaped. Five real bubblewrap invocations preserve read-only stream.mjs/session.json and distinguish helper SIGKILL from handled CLI errors at 1/137. Source/pin scans, whitespace and append-only prefix checks pass. Evidence and report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md and coder-stall-fix-r3-evidence/. Local review candidate only; independent exact-commit review remains with the lead. No push, stash, amend/rewrite, forge write, real model call, contact with 10.1.1.53, deployment or live operation.
## 2026-11-06 — MVP 3 coder-stall r3 review F2 drain-window correction (#797), lead fix
Independent r3 review of 05fdfef9e..6c123ce67 returned CHANGES with one P2: stream.mjs wrote its fd-3 `complete` token before stdout had drained a large answer, so a helper killed in that window exited 137 with partial stdout and empty stderr, indistinguishable from a complete CLI error. Correction: the helper acknowledges only from the stdout write callback (an error there acknowledges `invalid`), and the token names the exit status (`complete <status>`). adapter.sh accepts only a token equal to `complete $HELPER_STATUS`; a bare or mismatched token gives the fixed diagnostic, and status 0 becomes 2. Complete CLI errors at 1/137 keep their bytes, status and empty stderr. Helper digest pin updated to ed1a052bdc1bf0bc38c28af684d7e67bc0dd47328e05f0c0a3b7e44cad625978. New regression in model-activity.test.mjs (8 MiB answer, unread stdout, helper SIGKILL) failed on 6c123ce67 (empty stderr) and passes after the fix. Whole-file matrix: 31 files, 421 pass, 0 fail, 0 skip, sequential, Node v24.22.1. Reviewer probes rerun against the fix: partial-drain reproducer passes; helper-failure 4/4; managed-hostile 13/13; confined bubblewrap probe passes; boundary probe passes after one fixture was adapted from the old bare `complete` token to `complete 19`, plus new mismatch and legacy-bare-token cases that now produce the diagnostic. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r4-evidence/. Local commit only; needs a fresh independent review. No push, stash, real model call or contact with 10.1.1.53.
2026-11-05 | #797 ui797 support implementation phase | Explicit lead charter from 05fdfef9ea1fdddf58c725c04f229ca8559fc054 on feat/ui797-support: local isolated OIDC/TLS, persistent CDP browser CLI and durable metered campaign supervisor only under packages/cli/live. Queue next refused linked worktree (exit 2); canonical queue/forge ownership remains with lead. Initial author tests identified explicit-config selection and browser evidence-count defects, corrected locally; real browser SSO and full focused gates in progress. No .53/live access, push, runtime/role/contract edits, attribution trailers or stash.
2026-11-05 | #797 ui797 support author candidate verified | Added local single-subject HTTPS OIDC and fixed TLS forwarder, persistent real-input Chromium CLI with private NSS trust, and external durable per-model metered-stop ledgers. Final focused run: 34 pass / 0 fail / 0 skip (identity 6, browser 5, budget 23); dependency regressions: 111 pass / 0 fail / 0 skip; supported processing CLI regressions: 4 pass / 0 fail / 0 skip. Initial configuration-selection, Basic credential decoding, browser readiness/count and shutdown issues were corrected; all final gates pass. Source remains confined to packages/cli/live plus append-only protocol records. Local commit authorized; independent exact-candidate review and supervised-service acceptance remain with lead. No .53/live activity, model spend, push or product-runtime/role/contract changes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-report.md.
2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md.
2026-11-06 | #797 ui797 support R3 P1 corrections verified | Before: 19e6cb0f1 still emitted WebRTC STUN/TURN UDP despite its flag, and unavailable ledger directories bypassed halt and terminated serve. After: mandatory Bubblewrap network/PID namespaces with an isolated, up loopback; a single namespace TCP relay over a 0600 Unix socket to the existing origin-only proxy, preserving private NSS/TLS validation, control socket permissions and awaited browser-before-proxy cleanup. Real Chromium regressions from both origins create nine ICE agents against six independent IPv4/IPv6/127.0.0.2 STUN/TURN UDP listeners and three TCP listeners; zero datagrams/connections, existing WebSocket/HTTP/SSO/CA negatives retained. Campaign initialization now exclusively pins owner/config/binding/ledger evidence beside the campaign, independently of the ledger volume; missing/renamed/0500 storage still attempts supported CLI halt, explicitly reports nondurable latch/receipt, preserves evidence and never recreates an epoch. Serve survives failures and reasserts each tick/Resume, retaining its stop across storage recovery; invalid/foreign/changed targets refuse. Older intact ledger format preserved. Final sequential whole files: identity 8, browser 10, budget 43 = 61 pass / 0 fail / 0 skip / 0 cancelled. All R1/R2 probes rerun: zero forbidden TCP/UDP, all four storage variants count one halt, original latch preserved, stale-lock/corruption/cap/cutoff/identity negatives retained, SIGTERM/SIGKILL/startup-failure survivors zero. Original R1 general probe exits 1 at its correctly refused split-route setup. Independent final sweep: zero owned processes/listeners at fixed and 43 recorded endpoints; scratch sweep parser corrected before its terminal run. Exactly packages/cli/live plus this single entry and one SESSIONS line; linked-worktree queue refused, no queue/forge writes, push, stash, model call, .53 contact, host network/DNS/trust changes or unrelated-session cleanup. Local implementation ready for independent exact-head review; owner-service successful quiescence remains a later acceptance gate. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-evidence/ and ui797-support-fix-r3-report.md.
2026-11-06 UTC | #797 ui797 support R4 review corrections verified | Before: unchanged 905d313cd reproduces 13 host pathname D-Bus connections/11 AUTH EXTERNAL requests, four damaged-sidecar cases with zero halt attempts, and a coherent epoch edit accepting 4 of 8 real stored tokens against cap 5. After: empty Bubblewrap mount view with read-only system/browser dependencies, minimal dev/proc, private tmpfs tmp/run/home, browser-owned writable evidence/NSS mounts and explicit environment; actual Chromium D-Bus traffic zero on HTTP/HTTPS, diagnostic pathname/abstract/TCP attempts fail, UDP listener sees zero, synthetic 0600 file/config/runtime paths hidden. Private NSS trust and unchanged production Console OIDC sign-in pass. Version-2 ledger independently pins config bytes; sidecar read/parse/mode/deletion and semantic corruption stop with explicit evidenceErrors and counted supported halts, preserving damage. Serve retains verified target/config/epoch evidence and revalidates marker/root/store/config before targeting and immediately before halt. Normal-path independent campaign hashes/epochs must agree; real coherent-epoch-edit and zero pin hash halt without journal reset. Modern sidecar deletion stops; legacy version-1 requires intact independent config-byte proof and an unpinned legacy ledger refuses, without migration/reinitialization. README requires Bubblewrap/user/network/PID namespaces on every browser host and states identity is HTTPS 17443/oidc behind the Console TLS forwarder, never 17442. Sequential whole files budget 89, identity 8, browser 13, request-lifecycle-live 2 = 112 pass / 0 fail / 0 skip / 0 cancelled. Both unchanged reviewer probes first fail semantically on baseline, then all six controls pass; three further retained probes pass, 31 network records/12 denied CONNECTs and both WebRTC origins retain zero forbidden traffic. External-reader positive control, changed-path 64-hex pin search, ten syntax checks and whitespace pass. Development missing import and explicit PWD assertion corrected; evidence retained. Cleanup finds zero processes from this run, no fixed test listeners and empty/removed TMPDIR; 29 older other-session daemons untouched after overbroad sweep was narrowed to this run. Five packages/cli/live paths plus this entry and one SESSIONS line only; original log bytes preserved. Queue read refused linked worktree; no queue/forge writes, push, stash, model spend, real secrets, .53/live-service/host-trust changes or attribution trailers. Local implementation ready for independent exact-commit review; owner-service quiescence remains outside this author charter. Report/evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md and ui797-support-fix-r4-evidence/.
2026-11-06 UTC | #797 ui797 support R5 conflicting-ledger correction verified | Before: fce831c05 let a valid pin and a v2 ledger disagree on target and still halted the ledger's binding; the reviewer's unchanged target-conflict probe sent 8 halts to the wrong business/config (business-only corruption and a foreign ledger, check and serve startup). After: checkTarget runs corroborate() on every tick. A ledger target directs a halt only when the owner campaign, the owner pin, or the live binding of the owner campaign names the same target; otherwise the owner pin replaces it only when it matches the owner campaign hash, the live config/marker/root/store and the live config digest (evidence 'ledger-target-changed'); anything else refuses with campaign-instance-changed. Retained-target comparison stays as the second guard. Four new regressions (check, serve startup, running service, no corroboration) fail 4/4 on r4 source and pass after. Probe after fix: 0 wrong halts in all six cases. Sequential whole files budget 93, identity 8, browser 13, request-lifecycle-live 2 = 116 pass / 0 fail / 0 skip. No push, stash, .53, live service or model spend. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r5-fix-evidence/.
2026-11-06 UTC | #797 ui797 support R6 matching-damage and ledger-path correction verified | Before: 85e1ec5d1 rule (b) accepted a pin whose binding.business had been damaged to match a damaged ledger, and a foreign pin restored with the invoked ledger path; the reviewer's probe sent 8 halts to business B in check and serve startup. serveBudget re-read opts.ledger every tick, so a programmatic caller that mutated it moved stop/halt receipts to the replacement ledger. After: a pin corroborates a ledger target only when its binding names its own campaign's instance/config/business/owner and, while the owner campaign is readable, the owner's too. serveBudget freezes the startup campaign/ledger paths and retains the ledger path with the startup target; a running service accepts an exactly unchanged startup ledger as its own corroboration. Tradeoff (recorded): with no retained evidence, an owner business/config edit while the pin and ledger are intact now refuses (ambiguous target, indistinguishable from a forged pin+ledger pair) instead of halting the original; a running service still halts its retained startup target with reason campaign-changed; owner cap/epoch edits and sidecar-only damage still stop. Proof: 4 new regressions, 3 fail on 85e1ec5d1 and all pass after (the running matching-damage case already refused on r5); whole files budget-supervisor 97, isolated-identity 8, ui-browser 13, request-lifecycle-live 2, all pass/0 fail; reviewer probes with r6 expectations: matching-damage 6 cases 0 wrong halts, ledger-path 2 halts to the startup binding with stop/halt receipts only in the startup ledger, adversarial 104 cases verified (3 owner-retarget cases refuse in check/startup), r4 target-conflict exit 0. Evidence /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r6-evidence/. Local commit only; independent r6 review next.
## 2026-11-05 — MVP 3 D3 requirement texts in continuations implemented (#797)
Before: branch fix/mvp3-req-texts-797 off 62bece4a9da803d0734b435d157578ea5c8d2001 (planning-gate fix) in the T3 worktree; coder/reviewer could not read the product's approved requirement records (D3), and list_requirements remains PM/CTO-only by row 90 (#1584), asserted unchanged by packages/harness/tests/tools.test.mjs. Scope chosen by the lead: no role/tool widening; the bus itself quotes the task-cited approved texts into the implementation/review continuations it already sends.
After: broker #continuationQuote resolves, host-side and never from model input, this request's task.created citations (same product, exact approved snapshot) through the same catalog resolver behind the scoped requirements verb (#requirementsFor), bounding titles+texts at CONTINUATION_QUOTE_BUDGET=16384 bytes with a truncation+ask-PM line. request-continuation renders the block for push_working/review_verdict only, framed as quoted owner-approved data, never instructions; intents/evidence digests unchanged (quote lives in the message body only). request-lifecycle resolves the quote before its journal transactions (resume path; advance reuses the pre-transaction catalog.resolve result, covering the review→implementation changes loopback). Unresolvable or revoked snapshots and missing citations degrade to an ask-PM line without blocking or throwing. roles/coder.md and roles/reviewer.md gained one duty note each; no roles/*.json, tools.mjs gate, schema or authority change. New regressions: exact text/snapshot/records-commit/task citation with foreign-request, foreign-product and unknown-id isolation on a fresh send; reviewer quote; stale-drift and revoked-snapshot fallbacks without throw; budget truncation ordering. Whole files: bus+tasks 197 pass/0 fail/0 skip; harness+business 110 pass/0 fail/0 skip (Row 90 assertion green, coder/reviewer still lack list_requirements); adjacent recovery/limits/cli-lifecycle/launch 45 pass/0 fail/0 skip; 352 total, git diff --check clean. Initial runs failed three new tests ([draft] status not in snapshot rows; append-only request_snapshots/events triggers; successor reuses a waiting unroutable message) — corrected the tests, not the guards. Receipts: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-*.tap and req-texts-fix-r1-report.md. No push, merge, runtime write or product-acceptance claim.
## 2026-11-05 — MVP 3 D3 requirement continuation texts, implementation R2 (#797)
Before: exact R1 baseline 6e5cf26e7 on the owner-authorized fix/mvp3-req-texts-797 worktree. Independent review returned CHANGES: approved titles/bodies could forge PM/system/continuation lines, and catalog lookup could invalidate approval while resume dispatched obsolete implementation state. Corrected negative control ran the whole continuation file against unchanged production: 31 pass / 7 fail / 0 skip, including all four hostile coder/reviewer field cases and one persisted obsolete revision-7 coder message. Initial hostile fixtures included forbidden NUL and were rejected at PRD validation; removed that fixture character only, retained the initial receipt, then reproduced the actual containment failures before changing source.
After: every requirement field is rendered in one JSON record per line under explicit data markers, with quotes/backslashes/control characters and Unicode controls/formatters/line separators escaped. The 16,384-byte limit still counts raw UTF-8 titles plus texts before encoding; metadata and escaping add message bytes. Budget/codepoint/later-ID/truncation/ask-PM semantics remain. After async lookup, resume rereads revision/phase/snapshot, discards changed bindings and re-settles before any continuation write. Real-catalog regression commits a new owner spec at implementation revision 7, consumes/relaunches the coder and proves discovery revision 8 with no newly persisted obsolete coder message. Four hostile-field/consumer cases and two encoding/budget cases added; changed continuation file 38/38. All 225 bus/tasks/harness/business whole files ran sequentially with Node v24.22.1, per-file pipefail/tee and fresh /mnt/storage/scratch/tmp/XXXXXX TMPDIR: initial 2287 pass / 1 fail / 0 skip, solely the known claude-session.test.mjs:329 row-66 correction-notice flake on turn 3; unchanged whole-file rerun 10 pass / 0 fail / 0 skip. Terminal repository cases 2288/0/0; reviewer-suite author rerun 11/0/0 plus byte-identical real-catalog reproduction 1/0/0. Initial failure remains recorded, not relabeled. No other positive failure or skip. Intent/evidence/digest compatibility, deduplication and coder/reviewer list_requirements exclusion pass; protected role/tool/schema hashes unchanged. Changed-path hex-pin search has no pin, positive control finds four. One local commit authorized; no push, amend, rebase, force, stash, remote/live/model/secret/fleet/.53 action. Queue refused linked worktree, no bypass/move. Report and per-file evidence: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-fix-r2-report.md and req-texts-fix-r2-evidence/. Ready for independent exact-commit review; no R2 approval, CI, deployment or product acceptance claimed.
R3 encoded-budget correction (#797 D3, 2026-11-06, MVP3 lead): r2 review P2 found the raw 16,384-byte budget let JSON/control escaping grow one quote to ~98 KB, so stateBlock+continuation reached 142,978 bytes and the runner's MOSAIC_REQUEST exec failed E2BIG. request-continuation.mjs now applies QUOTE_BUDGET=16384 to JSON-encoded title+text bytes (same escaping as the rendered records), cutting on whole code points so no escape splits; later ids stay listed and the truncation/ask-PM notice is set by the render-time cut. broker CONTINUATION_QUOTE_BUDGET stays as a raw pre-bound and now also flags a title-only cut. Tests: two budget tests rewritten for encoded bytes, one control-escaping growth test, one execution-boundary regression (50-task state page with 250-character CJK titles + maximal escaping, runner stateBlock/turnRequest, /bin/sh spawn with MOSAIC_REQUEST). Negative control on 50a8d1a43: 3 of 4 new tests fail, boundary test E2BIG at 142,978 bytes. Fixed: request-continuation.test.mjs 40/0/0; bus+harness whole suites 1851 pass/0 fail/0 skip, Node v24.22.1; git diff --check clean. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-r2-fix-evidence/. Local commit only; no push, merge or live action.
R4 request-space correction (#797 D3, 2026-11-06, MVP3 lead): r3 review P2 found the quote serialized every cited task reference and record field outside the 16,384-byte title+text budget; 800 valid references plus the real 50-task state page made a 144,399-byte MOSAIC_REQUEST and exec failed E2BIG. request-continuation.mjs now derives the room from the host exec limit (REQUEST_ENV_MAX 131,056 = 131,072 minus "MOSAIC_REQUEST=" and NUL), the worst-case state page (STATE_BLOCK_MAX 40,724 for 50 tasks) and a 2,048-byte turn frame, leaving CONTINUATION_BODY_MAX 88,284. The quote gets that minus the lead. It charges the record skeleton for every requirement first, so every cited id stays listed; titles then texts fill min(16,384, remaining) with whole code-point cuts; task references fill what is left and the rest are counted in tasks_omitted; the truncation/ask-PM notice is kept. If even the skeleton does not fit, the block is replaced with a one-line ask-PM notice. Negative control: the three new tests against b3ee8a2bc's renderer fail 3 of 43 (800-reference case E2BIG at 144,399 bytes); with the fix the whole file passes 43/0/0. Bus+harness sweep 1853/1/0: the one failure is harness claude-session.test.mjs row 66 (scripted reply order), outside this diff; the whole file alone failed once and passed once on rerun, recorded as a pre-existing flake. Follow-up not fixed here: the lead (intent evidence with state.tasks and events) is itself unbounded, so a request with roughly 4,500+ tasks still exceeds the exec limit with no quote at all; that needs a file transport for MOSAIC_REQUEST or bounded intent evidence in its own row. Local commit only, awaiting independent r4 review.
Correction to the R4 request-space entry (#797 D3, 2026-11-06, MVP3 lead): the stated limits were off by 50 bytes. STATE_BLOCK_MAX is 1024 + 50 * (2 + 41 + 1 + 750 + 1) = 40,774, not 40,724, so CONTINUATION_BODY_MAX is 88,234, not 88,284. The code was always computed from the expression; only the prose was wrong. The "roughly 4,500+ tasks" follow-up threshold is also wrong for the real fixture: with two evidence event ids per task, the lead alone passes the 88,234-byte static body budget at about 972 cited tasks (92,363 bytes), and the bare request there is 130,955 bytes, 101 bytes under the 131,056 exec limit.
R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that both ask-PM fallbacks (oversize 131 bytes, unresolved 153 bytes) were appended without checking the room. A lead with 0-130 bytes left grew the body past CONTINUATION_BODY_MAX, and the real 972-task lifecycle case went from a 130,955-byte request that execs to 131,086 bytes and E2BIG. quoteBlock (now exported and outside createContinuations) holds every outcome to max(room, 0): the full notice when it fits, else the 47-byte "Ask the PM for the approved requirement texts." line, else nothing. That includes the final over-room check on a built block. New tests: a sweep of rooms -3..160 for push_working and review_verdict with both the unavailable and the oversize quote, checking the exact notice choice at each boundary; and the real 972-task lifecycle case (the shared manyCitedTasks helper, now also used by the 800-task test) asserting no fallback is appended and the request execs. Negative control in a scratch worktree at 2297d532e: both new tests fail, 972-task E2BIG at 131,086 bytes (the boundary sweep fails on the missing export there). With the fix the whole continuation file passes 45/0/0 on Node v26.12.1. The unbounded lead stays the recorded follow-up (file transport for MOSAIC_REQUEST or bounded intent evidence); in this fixture it reaches E2BIG on its own just above 972 tasks. Local commit only, awaiting independent r5 review.
2026-11-06 | #797 MVP3 project archive round 2 — implementation charter | Owner-directed correction of round 1 candidate 3e21d239b: close archived thread/session execution and product/request/PRD/task admissions, preserve accepted-work completion and history, restore archived navigation and the two CSS hooks. Local feat/mvp3-project-archive-r2 only; no push, merge, deployment, delete, role/contract changes or fleet changes. Cross-lane budget-supervisor schema correction remains for integration. Reviewer-probe regressions first reproduced 8 failures (58 passes); exact runs retained under /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/.
2026-11-06 | #797 MVP3 project archive round 2 — author verification complete | Archive admission now spans queued thread/session execution, product/repository intake, PRD activation, task requirements, bootstrap effects and request-bound manifests/continuations; accepted results, task/review/delivery receipts, cancellation, release/end and history remain usable. Archived navigation exposes saved threads/room history and Unarchive with only the two archive CSS hooks. Final full bus 1618/1618; WebUI 583/583; other path consumers 230/230; final affected lifecycle/launch files 67/67. CLI path gate 585/587: two dependency copy-fixture failures reproduced serially, then whole case2-install file 28/28 after removing an accidental yaml/yaml self-link; all other 31 files passed. Every earlier failed run and correction retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/. Store/schema remain 3t; cross-lane budget-supervisor correction deferred as directed. Local candidate for independent review only; no push, merge, deployment, delete, role/contract or fleet changes. Report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md.
2026-11-06 | #797 MVP3 project archive round 3 — implementation and verification | Before: r2 head 1c33c342b had two P1s: stale final launch admission and an omitted origin-chat binding downstream. After: a single broker snapshot fences thread and manifest at launch; shared complete request-binding checks fence bootstrap effects, PRD activation and task/requirements admission while preserving accepted receipts and reads. Added permanent race/origin/shared-repository regressions and operator guidance. Node 26: exact-parent negative 5 behavioral failures / 1 control pass; final regressions 6/6, bus excluding socket file 1614/1614, WebUI 583/583, corrected whole release file 226/226. The separate project-files socket test timed out after five passes (exit 124, one cancelled wrapper); C8 had 569/587 after fixture timeouts were fixed, with 17 release-fixture failures subsequently corrected and the known parent adapter-exited-127 failure remaining. Initial failures, full commands and comparisons are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-evidence/ and the report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. No schema/store, supervisor, role, contract or policy changes; no push, merge, deployment or live acceptance. Independent limited re-review remains.
2026-11-06 | #797 MVP3 project archive r4 — integration and author verification | Before: r3 f23a998ee and integration 5255e4e74 conflicted; the reviewed resolution tree reproduced WebUI 625 pass / 15 fail. After: exact reviewed merge 6a655a5cb, archive route fix and readable unknown archive state with real broker refusal evidence. Node 26 final WebUI 644/644, bus excluding project-files 1642/1642, harness 277/277, CLI whole-file partitions 1198 pass / 1 FED R7 skip. Candidate and integration parent project-files each timed out after five passes with three socket cases unfinished; this is not a green whole-bus gate. Earlier focus and Claude row 66 failures, clean reruns, comparisons and exact commands are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md and project-archive-r4-evidence/. Local commits only; independent delta review remains, no push or deployment.
2026-11-06 | #797 MVP3 project archive r5 — WebUI implementation | Before: r4 head 37f38d8e1 exposed unguarded request and session writes when archive lookup failed, and mapped guarded attach's project-archived refusal to 503 outcome-unknown. After: the unknown-state thread keeps reads and guarded writes available with a warning, suppresses unguarded controls and handlers including library revert, and reports guarded attach as 409/refused; live and archived recovery are tested. Node 26: final WebUI 649 pass / 0 fail / 0 cancelled / 0 skipped; exact-r4 negative 5 behavioral failures, real authenticated archived-origin attach 409. An initial diagnostic full run had 647 pass / 2 fail while source was changing (session catalog recovery and known-archived clear cancellation); both pass on exact r4 and after correction. All iterations, comparisons, inventory and commands: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md and project-archive-r5-evidence/. Bus/harness/CLI not run for the WebUI-only change. Queue refused this linked worktree; no queue move, push, merge or deployment. Local implementation candidate for independent review.
2026-11-06 UTC | #797 stall-guard implementation started | Pinned 5df42478b6c1ef5e22e2eb6c3fa8605722ab5f77 on feat/ui797-stall-guard. Before: both pi print mode and Claude text mode emit the turn reply only at completion, so a live first turn can be stopped despite model output; a chief can also immediately repeat an instance-running launch refusal. Scope is event-based first output and bounded launch refusal handling with focused and package gates; no configuration, role, schema, proxy initialization or live-instance change. The queue command refused in the linked worktree; no canonical queue access.
2026-11-06 UTC | #797 F6 streamed first output | Managed pi JSON events and Claude stream-json partial events pass through one harness monitor; substantive text, thinking and tool-use events mark a fixed output token while stdout remains the final answer. Six real confined runner/adapter controls use a six-second guard: both streaming turns survived and both headers-only and keepalive-only cases stopped; both streaming tests failed on the pinned base and all four stall controls passed there. Harness suite 250 passed; CLI main partition 909 passed, one failed and one skipped, with its 53-test file rerun green; the failure also reproduced on the base. No guard threshold, proxy initialization, configuration, schema or role changes. Release and lane partitions are retained under /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-evidence/.
2026-11-06 UTC | #797 F7 launch refusal guard | The typed launch path now reports instance-running as an existing run to wait for, serializes calls per instance and delays a new call for 30 seconds after a refusal; a disconnected caller cannot start a queued launch later. There is no automatic retry, an unknown outcome remains distinct, other instances are independent, and success clears the delay. Five new tests failed on the F6 base and passed after the fix; the complete harness suite passed 255/255. The backoff lives for the requesting runner's lifetime and resets on its restart. No policy, role, schema, configuration or live change.
2026-11-06 UTC | #797 stall-guard review fix round | Before: managed stdout added or omitted bytes relative to adapter text mode, a signalled child lost its exit-137/stderr receipt, and no-route incurred the instance-running cooldown. After: adapter-specific byte rendering, a fixed signal-name diagnostic and signal exit status, and a 30-second cooldown only for instance-running; no-route can be retried after the route is set. New regression controls fail on 3e754c96a; real local-API adapter comparisons and owned-child death receipts match base behavior. Node 26 harness rerun 270/270; CLI whole-file coverage 1,197 pass and one existing federation skip after two whole-file reruns (one known timing flake and one interim stdout-fallback regression, both rerun green); isolated release 251/251 and gate-lane 61/61. The managed-session copies pass 20/20; all six real guard scenarios pass. Bus project-files independently reproduces the reviewed base hang under an external timeout. Initial namespace-only project-files attempt hit interpreter-unsafe; corrected ordinary-user run retained. Queue refused linked worktree; no push, deploy, model calls, live host, secret, policy, role, contract or schema changes. Exact evidence and remaining review boundary: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md.
2026-11-06 UTC | #797 stall-guard integration merge | Before: stall-guard tip 6b2c925b9 and integration 5255e4e74 each supplied a Claude activity route; managed Pi also had two potential marker producers. After: BASE's Claude stream helper is authoritative, the Pi monitor is retained and its extension duplicate is disabled for managed turns, runner metadata suppression and newline filtering coexist, instance-running-only cooldown remains, and known/unknown child-signal receipts survive on both paths. Claude empty-delta handling follows BASE's selected helper; helper SHA pin updated. Node 26 final harness 309 pass, CLI main/lane/release 885/61/251 pass with one FED R7 skip, separately verified guard E2Es 11 pass, bus excluding project-files 1615 pass. Bus project-files had 5 pass then timeout/cancellation on both candidate and exact BASE; gate is not green. One earlier harness run failed the known Claude row-66 timing assertion (exact BASE full harness 277/277 and two file runs 14/14; older-base reproduction recorded by prior review); final rerun passed. An initial signal test invoked the removed Claude monitor and was adapted. Queue refused linked worktree; canonical queue read returned nothing. Local merge candidate only, no push or deployment. Full counts, caveats and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md and stall-guard-int-evidence/.
2026-11-06 UTC | #797 supervisor schema guard implementation | Before: the 3s-only accounting guard refused the 3t archive store. After: the lead-authorized closed 3s/3t fallback preserves 3s compatibility and accepts 3t; store.mjs performs load-time file reads and in-memory schema/migration execution, so it is not imported into the supervisor. The real-Store current-version regression failed on BASE with test-store-schema-refused; synthetic 3s accepts, 3r/3u/missing refuse. DDL diff adds only project_archives objects. README documents owner diagnosis and restoration or campaign end without reset. Sequential Node 26 whole-file gates: budget 102/0, isolated identity 8/0, browser 13/0, lifecycle 2/0; no skips. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/; local candidate for independent review, no push or deployment.
2026-11-06 UTC | #797 resume chief implementation | Before: halted and launches-disabled receipts counted as the chief's launch attempt, so a resting request could not relaunch after resume at an unchanged prerequisite. After: continuation lookup excludes only those host-gate receipts while preserving visible refusals and earlier real attempt failures. Three real-bus/launcher regressions fail behaviorally on 13f81354e and pass with the fix, including bounded repeated ticks and a launch-failed retry guard. Node 26 touched file 12 pass; bus excluding project-files 1642 pass; serial CLI 1207 pass, 1 base-reproduced FED R7 skip. Initial concurrent CLI 1199 pass/8 fail/1 skip: four timing cases passed serially on base; four Claude wrapper failures reproduced on base and passed with the direct installed binary. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/resume-chief-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/resume-chief-report.md. One local commit for independent review; no push, merge, deployment or live host action.
2026-11-06 | #797 MVP3 project archive r6 — WebUI implementation | Before: r5 head 8c3f48113 exposed server-unguarded request, accepted-clear cancel, library Revert and project settings Save controls on a known-archived project. After: known archived state suppresses those controls and fences stale handlers while preserving readable history, settings, Unarchive and the guarded attach picker. Node 26 frozen WebUI 654 pass / 0 fail / 0 cancelled / 0 skipped; five new tests plus the changed navigation assertion fail on exact r5 production for expected behavior; real authenticated Store/Broker probes verify guarded send, resume, clear and attach refusals. The first whole-suite run was diagnostic because the final disposed-form guard was added during it; it also reported 654/0/0/0. Detailed inventory, iterations, commands and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md and project-archive-r6-evidence/. Bus/harness/CLI suites not run for WebUI-only change. Queue refused this linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review.
2026-11-06 | #797 MVP3 project archive r7 — WebUI implementation | Before: r6 head 51892cf95 lost known archived state during failed refresh, reopening repository Save and room Open and exposing guarded thread controls. After: settings, thread and room derive archive state from the displayed metadata through pending and failed reads; successful live reads restore controls, and refusal clearing remains intact. Node 26 final WebUI 658 pass / 0 fail / 0 cancelled / 0 skipped; four browser regressions fail on exact r6 for expected behavior. An earlier diagnostic whole suite had one 1440px remote-fixture timeout; candidate and r6 isolated reruns each passed 4/4, and the final whole suite passed that case. Room Send remains disabled on failed membership reads as before. Full iterations and comparison: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md and project-archive-r7-evidence/. Bus/harness/CLI not run. Queue refused this linked worktree; no push, merge, deployment or server guard change. Local implementation candidate for independent review.
2026-11-06 UTC | #797 MVP3 project archive r8 — WebUI implementation | Before: r7 head 5bf07f35f temporarily withdrew live thread controls and showed archive-unavailable notices on every same-thread refresh without a failed read. After: a non-navigation redraw retains the resolved archive state only for the same thread with its project in retained metadata; missing retained metadata on failed reads remains unknown. Two delayed refresh ticks retain live request, session, share, cancel and revert controls; a thread switch still withdraws them and stays unknown after failure. Node 26: exact-r7 negative reproduces disabled session control, switch control passes; focused archive browser files 11/11; full WebUI 660 pass / 0 fail / 0 cancelled / 0 skipped, versus r7 658/0. The 1440px remote fixture passed. Bus/harness/CLI not run; project-files excluded. Queue refused linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md.
2026-11-06 UTC | #797 MVP3 project archive r9 — WebUI fix for the r8 review P1 | Before: on r8 head 4c85d3784, a same-thread redraw kept the last resolved live controls. If the fresh lookup then found the project archived (listed or resolved) or unknown, the mounted request panel still offered mode, cancel, revoke, resend and answer, and the session, share, unshare, cancel and revert controls stayed enabled until the slower session and history reads finished. After: right after the fresh project lookup, a same-thread refresh that resolves archived or unknown withdraws those controls and remounts the request panel fenced, before the session read. The navigation and initial-load paths are unchanged. New browser test with the sessions read held open covers listed archived, resolved archived and unknown, and asserts no write. Node 26: negative control on exact r8 fails for behavior ("listed archived: [data-request-mode] offered while the sessions read is pending"); the r8 reviewer probe goes from 3/1 on r8 to 4/0; focused archive browser files 12/12; whole WebUI 661 pass / 0 fail / 0 cancelled / 0 skipped, versus r8 660/0. Bus/harness/CLI not run; project-files excluded. No push, merge, deployment or server guard change. Local candidate for independent review. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/.
2026-11-06 UTC | #797 write_record JSON object compatibility — implementation and verification | Before: JSON-encoded requirements and decisions were refused and project-record-invalid detail was lost before the agent. After: strict object parsing preserves object-form bytes, and only record.write project-record-invalid details reach the tool result; docs remain Markdown. Exact-base negative control: seven behavioral failures. Node 26 serial gates: touched file 7/7, project 50/50, harness 316/316, bus excluding project-files 1642/1642, CLI 1204 pass and one federation acceptance skip reproduced on base. An initial candidate focused run failed one test due to a duplicate code prefix; corrected and rerun green. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Linked-worktree queue refused; local candidate for independent review, no push or deployment.
2026-11-06 UTC | #797 Files polish — implementation and author verification | Before: caller and Files module each rendered a heading, and request redraws replaced settled content with a loading message. After: callers retain the single heading; cloned DOM content is cached per deduplicated request list or validated thread key, fenced against older mounts and cleared at sign-out. Exact-base negative control: 8 behavioral failures, 3 passes. Node 26 serial authorized browser gate: 91 pass, 0 fail, 0 cancelled, 0 skipped. An initial detached-container test timed out because its fixture awaited its own held request; the same timeout reproduced on base and the fixture was corrected. All evidence and limits: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Local implementation candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | #797 Files polish r2 — review r1 F1/F2 ordering fix | Before (86383a9d1): the per-key sequence fenced only cache writes, so an older same-key read in another live container painted stale files after a newer mount settled (F1), and a pending newer mount kept a completed result out of the cache, so a remount flashed "Reading files…" (F2). After: each mount takes a start order; a settle paints and caches only if no later-started mount for the key has committed, otherwise it shows the newer cached result; a pending mount never blocks an earlier completion. Reviewer probes ported as two tests; on 86383a9d1 they fail 2/13 for behavior, on the fix w15-files-browser 13/13 and the touched caller browser files 93/0/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
2026-11-06 UTC | #797 round A task evidence and request polling | Before: advance_request omitted missing per-task push/review chains, the coder continuation seeded the first task regardless of evidence, and missing request IDs were polled repeatedly. After: the broker names all incomplete task chains and supplies the same read-only result to continuation selection; scoped harness serialization exposes the refusal with the existing 300-character bound; thread-page readers suppress confirmed missing IDs until a new caught-up thread message. The owner authorized the narrow harness socket addition. Exact-base controls: five behavioral failures in the initial control and one in the bounded harness control. Node 26 serial named-file gate: 142 pass, 0 fail, 0 skip. An intermediate rerun was deliberately stopped after the owner clarified the bound; its log is retained. Queue refused this linked worktree. Local candidate for independent review; no push or deployment. Report and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-report.md and /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/.
2026-11-06 UTC | #797 round A fix r2 (Sol r1 P2 + two P3) | Before (97d670da9): a remembered request-not-found was retried only when the newest message on a caught-up thread changed, so a request opened with no new message stayed hidden; misses grew with paged history; an aborted read got a fabricated 404. After: misses are also cleared when the owner opens the thread (navigated) and when a mosaic-request-state for the id is seen anywhere on the page; timer refreshes and repaints still never retry. Misses are pruned to ids in the retained rows (200-row cap). An already-aborted read bypasses the memory and keeps the native AbortError. Remaining limit: the thread page reads only messages, so an event-only open with the thread left open and no navigation shows at the next message, navigation or reload. Closing that needs a server-visible thread signal, outside this change. New tests fail on 97d670da9 chat.js (2/2); webui chat-sessions-access, chat and browser files 40/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | #797 round A fix r3 (Sol r2 P2) | Before (16384bb12): a mosaic-request-state for an id advanced the thread's epoch only when a remembered miss was deleted, so a 404 still in flight from before the observation could record the miss afterwards and timer refreshes kept fabricating 404s for an open request. After: each observed state advances that id's own generation on every thread that retains it; an in-flight 404 records a miss only if both the thread epoch and the id generation are unchanged. Other ids' slow misses still settle. Generations are pruned to retained rows with the misses. New held-404 test fails on 16384bb12 chat.js at recovery; webui chat-sessions-access, chat and browser files 41/0/0 (Node 26 serial). Lead ruling: no third review round. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
2026-11-06 UTC | #797 round A fix r4 (Lead push gate red on 9d3eb9e11) | Before (9d3eb9e11): the chat.js fetch wrapper recorded a request-not-found from /api/requests/<id>/workspace as a missing request, then served a synthetic 404 to every later read of that id, including request panel rereads; request-browser.test.mjs failed 11/24 (its stub has no workspace route), base 6b84fe136 24/0. After: only the request's own read records a miss; a remembered miss still answers its workspace read in the page. New regression test fails on 9d3eb9e11 chat.js and passes on the fix. The r3 held-404 test raced Files' cached repaint under concurrency 3 (678/1; alone 5/5 pass); it now waits, bounded, for the refresh's reads to reach the server and still fails on 16384bb12. request-browser 24/0; whole webui suite except project-files 679/0/0/0 (Node 26, concurrency 3). Correction: the r1-r3 author gate ran only chat-sessions-access, chat and browser and omitted request-browser.test.mjs, which also exercises chat.js; that omission let this regression reach the push gate. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-push-gate/r4-*.
2026-11-06 UTC | #1847/#1848 fix row (Round B halt, key roundb-2-halt) | Before (1186cf7b1): the runner auto-replied a RESULT to every non-RESULT message, so chief INFO/RESULT replies to assistant RESULTs looped (thread 40df75c5, 54 messages, 1,036,184 assistant tokens); continuation notices keyed on business-wide launch/end/claim seqs, so every unrelated lifecycle event, including kill-switch ends during a halt, re-noticed the chief and drove 13 instance-running refusals (1,198,607 tokens). After: the broker adds in_reply_to_class to agent inbox rows and the runner sends no automatic reply to an agent's non-REQUEST reply to a RESULT (REQUEST, ordinary INFO and human/tester/user messages unchanged); continuation versions use only the target's own lifecycle events and claims, no notice while processing is halted or while the target's latest launch has not ended; the instance-running refusal tells the chief not to relaunch or relay. Builder 4048e0f60 (Codex Terra xhigh); Sol xhigh review r1 CHANGES: a halted broker restart failed on the new retry refusal (P1) and an older unmatched launch hid later ends, worsening #1851 (P2). Lead fix c04529c7b: startup request recovery waits while processing is halted or invalid, as the periodic pass does, and resume reschedules it; only the target's latest lifecycle event gates notices. Each regression test fails on its base. Gate on c04529c7b (Node 26.12.1): touched 123/0, bus except project-files 1648/0, harness 318/0, webui 678/1 then 679/0 on rerun (decide-browser I1 timed out under full-suite load; file alone 9/9 twice; no webui diff vs base). project-files.test.mjs hangs on base and candidate alike. Residual: a latest launch whose end is lost still suppresses notices until host recovery (#1851). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/step14/round-b/fix-1847-1848-gate/.
+21
View File
@@ -76,6 +76,8 @@ session's workspace and its own process group. Same contract, plus:
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
| `MOSAIC_MODEL_ACTIVITY_DIR` | Managed runner's existing writable run directory. Trusted model streaming events publish the fixed `output\n` token to `proxy-first-output` (0600, exclusive create). No event contents or credentials enter it. Claude's private stream reader and Pi's extension signal thinking, tool-use and text activity before final stdout; init/status, tool results and workspace writes do not count. |
| `MOSAIC_MODEL_ACTIVITY_SOURCE` | The harness module may set `adapter` in its bundle environment to declare model events authoritative and disable the runner's stdout activity fallback. Claude declares this because final stdout can be a synthetic error. Pi and mock retain the fallback. The runner reads the declaration from module metadata, ignoring any ambient environment copy. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
@@ -85,6 +87,25 @@ session's workspace and its own process group. Same contract, plus:
The layers each adapter relies on, and what they don't cover, are in
`packages/harness/README.md`.
Claude uses `--output-format stream-json --verbose --include-partial-messages`.
The adapter's `stream.mjs` consumes those events and prints a complete final
result, including error results, with the same newline as the CLI's text format.
CLI failures retain their nonzero exit status. Claude's model events alone mark
activity; its final stdout may be a synthetic error. The runner retains stdout
activity marking for Pi and mock adapters. Malformed or incomplete streams emit
only the fixed diagnostic and fail the turn; a failed or interrupted first turn
does not persist a reusable session ID. The helper is mounted read-only and
its digest is recorded in the launch bundle. A private completion pipe separates
handled CLI exits from helper launch/runtime failures, even if their exit codes
collide. The helper acknowledges only after its answer has been handed to
stdout, and the token names its exit status (`complete <status>`). The adapter
emits the fixed diagnostic if completion is absent, the stream is invalid, or
the helper's actual exit differs from the acknowledged status (the helper died
while its answer was draining), suppressing helper stacks and shell crash text. CLI stderr
keeps its existing channel; the CLI does not inherit the completion pipe. No extra
writable mount or completion file is needed. Pi retains print mode and signals
activity through its existing extension. Mock remains a test-only echo.
### A role's skills (row 64)
A managed session's skills reach both adapters as text in the bundle's
+34 -15
View File
@@ -11,6 +11,7 @@
# wrapped as `timeout -k 2 10 <gate> || exit 2`, and the tool limit (--tools).
# --bare is never passed: it turns settings hooks off.
set -eu
STREAM="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/stream.mjs"
need() {
eval "v=\${$1:-}"
@@ -60,7 +61,9 @@ fi
# Flags:
# -p one-shot: print the answer and exit
# --output-format text stdout is the answer only
# --output-format stream-json private model events; stream.mjs emits only
# --verbose --include-partial-messages the final result on stdout and marks
# first model activity before long tool work
# --system-prompt-file the generated prompt replaces the default, given
# as a file so no local user reads it in
# /proc/<pid>/cmdline (row 213); absolute, since
@@ -84,18 +87,34 @@ fi
# text in the system prompt (row 64)
ALLOWED=mcp__mosaic
[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
"$CLAUDE" -p "$MOSAIC_REQUEST" \
--output-format text \
--system-prompt-file "$PROMPT_FILE" \
--model "$MOSAIC_MODEL" \
--effort "$MOSAIC_THINKING" \
--restricted \
--tools "${MOSAIC_TOOLS:-}" \
--allowedTools "$ALLOWED" \
--permission-mode dontAsk \
--settings "$MOSAIC_CLAUDE_SETTINGS" \
--strict-mcp-config \
--mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
--disable-slash-commands \
"$SESSION_FLAG" "$SESSION_ID" || exit $?
# Completion travels on fd 3, independently of stdout and the CLI exit code.
# fd 4 preserves answer stdout; fd 5 preserves CLI stderr while helper/shell
# crashes stay private. The CLI never inherits the completion descriptor.
HELPER_STATUS=0
{
HELPER_COMPLETION=$(
{ node "$STREAM" "$CLAUDE" -p "$MOSAIC_REQUEST" \
--output-format stream-json --verbose --include-partial-messages \
--system-prompt-file "$PROMPT_FILE" \
--model "$MOSAIC_MODEL" \
--effort "$MOSAIC_THINKING" \
--restricted \
--tools "${MOSAIC_TOOLS:-}" \
--allowedTools "$ALLOWED" \
--permission-mode dontAsk \
--settings "$MOSAIC_CLAUDE_SETTINGS" \
--strict-mcp-config \
--mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
--disable-slash-commands \
"$SESSION_FLAG" "$SESSION_ID" 3>&1 1>&4 4>&-; } 2>/dev/null
) || HELPER_STATUS=$?
} 4>&1 5>&2
# The token must name the helper's actual exit status: "complete 0" for
# success, "complete N" for a complete CLI error. A helper killed after its
# answer was written but before exit leaves a mismatched status.
if [ "$HELPER_COMPLETION" != "complete $HELPER_STATUS" ]; then
printf 'claude adapter: invalid or incomplete model stream\n' >&2
[ "$HELPER_STATUS" -ne 0 ] || HELPER_STATUS=2
fi
[ "$HELPER_STATUS" -eq 0 ] || exit "$HELPER_STATUS"
[ "$SESSION_FLAG" = --resume ] || printf '%s\n' "$SESSION_ID" > "$ID_FILE"
+81
View File
@@ -0,0 +1,81 @@
// Consume Claude Code's SDK event stream privately. A complete final result
// reaches stdout, including errors, with the same newline as text mode.
import { spawn } from 'node:child_process';
import { writeSync } from 'node:fs';
import { constants } from 'node:os';
import { firstOutputMarker } from '../../packages/harness/src/first-output.mjs';
const mark = process.env.MOSAIC_MODEL_ACTIVITY_DIR
? firstOutputMarker(process.env.MOSAIC_MODEL_ACTIVITY_DIR) : () => {};
const [binary, ...args] = process.argv.slice(2);
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 5] });
let pending = '', answer, resultSeen = false, resultFailed = false, failed = false, killTimer;
const fail = () => {
if (failed) return;
failed = true;
child.kill('SIGTERM');
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000).unref();
};
function event(line) {
if (!line.trim()) return;
const item = JSON.parse(line);
const model = item.type === 'stream_event' ? item.event : null;
// Init/status, hooks, user/tool results and arbitrary local writes aren't
// model activity. message_start is the upstream assistant response header;
// partial blocks also cover models that first respond with thinking/tools.
const start = model?.type === 'message_start' && model.message?.role === 'assistant';
const block = model?.type === 'content_block_start'
&& ['text', 'thinking', 'tool_use'].includes(model.content_block?.type);
const delta = model?.type === 'content_block_delta'
&& ['text_delta', 'thinking_delta', 'input_json_delta'].includes(model.delta?.type);
const assistant = item.type === 'assistant' && !item.error
&& item.message?.model !== '<synthetic>' && item.message?.role === 'assistant'
&& item.message.content?.some(c => ['text', 'thinking', 'tool_use'].includes(c.type));
if (start || block || delta || assistant) mark('activity');
if (item.type === 'result') {
if (resultSeen) throw Error();
resultSeen = true;
// Let a normal CLI failure exit on its own, retaining its exit code.
resultFailed = item.subtype !== 'success' || !!item.is_error;
if (typeof item.result === 'string') answer = item.result;
else if (!resultFailed) throw Error();
}
}
child.stdout.setEncoding('utf8');
child.stdout.on('data', chunk => {
if (failed) return;
try {
pending += chunk;
let end;
while ((end = pending.indexOf('\n')) !== -1) {
if (end > 16 * 1024 * 1024) throw Error();
event(pending.slice(0, end));
pending = pending.slice(end + 1);
}
if (pending.length > 16 * 1024 * 1024) throw Error();
} catch { fail(); }
});
child.on('error', fail);
child.on('close', (code, signal) => {
clearTimeout(killTimer);
if (!failed && pending) {
try { event(pending); } catch { fail(); }
}
// Integrity and rendering are separate from the CLI's success classification.
// Buffer until close so corrupt/truncated streams never leak a final result.
if (!failed && (!resultSeen || signal)) fail();
if (signal) writeSync(5, `model child killed by ${signal}\n`);
const number = signal && constants.signals[signal];
const signalStatus = signal ? (Number.isInteger(number) && number > 0 ? 128 + number : 1) : 0;
const status = process.exitCode = signalStatus || code || (failed || resultFailed ? 2 : 0);
// Only a fully consumed stream whose answer has been handed to stdout can
// acknowledge completion, and the token names the status it will exit with.
// A helper that dies after acknowledging exits with a different status, so
// the adapter still reports it. The adapter owns the fixed diagnostic,
// including when this helper never loads or dies early. spawn's explicit
// stdio keeps fd 3 out of the CLI and its descendants.
const acknowledge = error => writeSync(3, failed || error ? 'invalid' : `complete ${status}`);
if (!failed && answer !== undefined) {
process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n', acknowledge);
} else acknowledge();
});
+3
View File
@@ -132,4 +132,7 @@ set -- \
# One-shot mode appends -p and the request (both safely quoted);
# interactive mode appends nothing - clean TUI.
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
if [ -n "${MOSAIC_MODEL_EVENT_MONITOR:-}" ] && [ "$INTERACTIVE" != "1" ]; then
exec node "$MOSAIC_MODEL_EVENT_MONITOR" pi "$MOSAIC_FIRST_OUTPUT_DIR" pi "$@" --mode json
fi
exec pi "$@"
+129
View File
@@ -1980,3 +1980,132 @@ are never rewritten or removed; corrections are new entries.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | Vikunja #415 and #416, .53 and .54 Console writes | bd79daffd on t3/console-writes-53-54 (base 1ff45c4ad): factory takes the paired --sso --send with a console.sso check before any unit write; .53 business document on provider proxy, route anthropic, account factory/proxy; library --send not done (needs the #385 refusal reverted, returned to the lead); cli case2-unit 88, stack-upgrade 25, case1-accept 53, business 8, 3 and 3, no skips; 2 mutants killed; no push, queue or PR action.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | editor-browser paste test round 2 (Vikunja #784) | e71222d1a on t3/784-r2 (base 0c6aa1a1e): test-only; a MutationObserver on the live document records handler-bearing elements entering the page and h.quiet() asserts none, so the CSP filter cannot hide an attach-then-remove; the reviewer's mutation now fails the paste and live-handler tests; the four earlier mutants still killed; editor-browser 17/17, editor 5/5, editor-provenance 6/6, no skips; no push, queue or PR action.
2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding.
2026-11-03 | Lead Codex GPT-6.1 Sol | Owner terminology enhancement (#798, Gitea #1840) | Created task covering user-facing prose and internal identifiers; compatibility inventory precedes implementation, owner-versus-granted-user authority preserved; lower priority than #797, active delegates steered to use user prose without overlapping a bulk migration.
2026-11-03 | Lead Codex correction to preceding #798 entry | The two #797 steer attempts were refused because both delegates were interrupted, not running; no steering was delivered. Read back both threads: Console baseline and failing tests exist; lifecycle investigation exists; no final reports. Resume the same checkouts with the terminology instruction, preserving progress.
2026-11-03 | Luna inventory, outcome recorded by lead Codex | #798 / Gitea #1840 user terminology including internal identifiers | Read-only report complete; lead verified consequential identity/config/event boundaries and recorded staged compatibility plan; linked-checkout queue refusal reported, no queue/source/host changes; enhancement remains open for implementation after #797.
2026-11-03 | GPT Astra lifecycle planner, integration by lead Codex | #797 / Gitea #1839 stages 2–4 | Two planning artifacts integrated; scoped requirements, protected bootstrap, guided/autonomous/attach, durable continuation and reviewed local HTML route specified; original-wording provenance supplied by lead; no source implementation/live acceptance/PRD approval claimed.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/b15e72fa | #797 / Gitea #1839 stage 1 ordinary Console answers | 2–9 authoritative choices, selected-option confirmation/receipt, full-context digest and lifecycle coverage; targeted broker 17 and WebUI 25, full bus 1407 and WebUI 509 passing with zero skips; initial unchanged T27 flake and locked dependency restoration recorded in BUILD-LOG; local commit only, controller review pending, no trackers or external state changed.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, original t3/b15e72fa checkout | #797 / Gitea #1839 Console fix round 1, reviewer I1 | Real-browser delayed inbox/trail and cached-error races RED 0/3 -> GREEN 3/3; originating generation/route guard protects answer-state mutation and HTML commit; covering 28/28 and full WebUI 512/512, zero skips; M1 warning retained, controller re-check pending; local commit only, no bus/lifecycle/editor/rename/tracker/live changes.
2026-11-03 | Lead Codex GPT-6.1 Sol integration | #797 / Gitea #1839 general Console answer buttons | Independent review and scoped fix re-review clean; integrated bus 1407/1407 and WebUI 512/512, zero skips; logs merged append-only and source matches reviewed worker head; code integration only, no running-instance deployment; continue durable pre-task intake.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/0c734dbd | #797 / Gitea #1839 lifecycle Task 1 | Durable pre-task intake, trusted origin/provenance, revision/plan-bound user selections, host-injected preparation/admission foundation and explicit setup waits; strict append-only 3n→3o offline migration with no old migration edits. Intake 26/26, required checks 41/41, full bus 1433/1433, business 296/296, bounded CLI restore/accounting 7/7 and sandbox import 1/1, zero skips; initial RED and schema/dependency/fixture failures retained or described in BUILD-LOG/report. Existing pinned dependencies restored via lockfile only. Controller owns queue/tracker, independent review and integration; tested local commit only; no subsequent lifecycle task or live/authority/Console changes.
2026-11-04 | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 lifecycle Task 1 review, fix round and landing | R1 needs fixes, fix bec358328, R2 I2 resolved, I3 residual to #800, I1 to #799; landed on refactor
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/request-lifecycle-bootstrap | #797 / Gitea #1839 lifecycle Task 2 | Protected project bootstrap: exact-add organization writer, journaled bootstrap executor (reserve, seed, working repo, mapping, records, original intent as owner, chat, activate), runtime activation of a receipt-backed additive product root, host request.admission gate for the launcher, #800 stale re-verify inside the organization lock (request-plan-stale), append-only 3o to 3p migration. Business 304/304, bus 1456/1456, brief 18/18, project+launcher 80/80, stack-release 225/225, zero skips; local commit only, no push.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | C1 link-confined seed and marker writes, C2 receipt-backed code.tmp kept as bootstrap-uncertain when unproven, I1 freshness recheck before every effect, I2 layout-based managed classification, I3 host preparePlans/admitPlan wired (named setup waits), I4 existing-project comparison. Brief 79/79, kept-green 49/49, bus 1477/1477, business 305/305, project+seat 267/267, stack-release 225/225, zero skips; no schema change; local commit only, no push.
2026-11-04 | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 lifecycle Task 2 review, fix round and landing | R1 needs fixes, fix 883003041, R2 left I1/I2/N1 residuals as a pre-.53 hardening follow-up; landed on refactor
2026-11-03 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7a-artifact-reader | #797 / Gitea #1839 lifecycle Task 7a | Bounded committed-artifact reader and real Git regressions; artifact 9/9, seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/; queue refused linked worktree, controller owns integration; local commit only, no push.
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a | Four backed typed intake tools, pre-task PM staff.request with original request provenance, actual pi/claude-code role manifests and production runner/MCP acceptance tests; PM duty and generated staff prose updated. CTO read manifests added while existing broker read refusal remains; intake_question absent because no backing verb. Gates request-tools 18/18, required 55/55, harness 223/223, bus 1477/1477, business 305/305, sandbox imports 1/1, zero skips; logs /mnt/storage/scratch/tmp/JHTDYm/. Parent owns review/integration/queue; local commit only, no push or authority/schema/contract/migration changes.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a | Optional journaled create operations, sync-only complete-scan adoption, host-only recovery with original attribution and PM follow-up resume; no migration. Recovery 21/21, required 61/61, tasks 121/121, bus 1477/1477, zero skips; diagnostics and parent reruns in /mnt/storage/scratch/tmp/CkjqAm/. Task 5b deferred, queue left to lead after linked-worktree refusal; one local commit only, no push or live changes.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/harden-project-bootstrap-executor | #802 bootstrap executor hardening | N1 working path confined before and inside the helper, I1 guarded() makes the freshness check and every protected write one synchronous run under the organization lock, I2 complete marker published before activation and readiness needs marker plus committed receipt at boot, reload and admission. Brief trio 90/90, bus 1488/1488, business 305/305, project+seat 271/271, launcher+stack-release 261/261, zero skips; no schema change; local commit only, no push.
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 | C1 permanent foreign-repo refusal, exclusive ownership claim and per-child internal-link/alternates checks; C2 async-held organization lock through records init, intent commit and rollback; C3 committed activate receipt before broker reload, fenced receipt failure and successful retry. All 16 added regressions fail on 52b283c8a; final focused 103/103, bus 1499/1499, business 307/307, project+seat 274/274, launcher+release including external cases 286/286, zero skips. Logs /mnt/storage/scratch/tmp/qRkZh1/; one local commit, no push, no role/contract/migration/live changes.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Proxy args, three-unit copy checks and ordered stop/re-render/start/recovery; run-sheet section 7 updated. Focused 9/9; required upgrade 34/34, case1-accept 53/53, case2-install 28/28, case2-unit 88/88, scratch acceptance 21/21, zero skips; lockfile dependencies restored after initial setup failures. Evidence /mnt/storage/scratch/tmp/JBjOT3/. Linked-worktree queue refusal left to coordinator; local commit only, no push or live/secret access.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers: 3p->3q migration, PM-only request.question, owner-only request.answer/attach/mode/cancel.console verbs, request-http.mjs routes with owner, same-origin and bounded-body checks, requests.js panel under the owner's chat message (mode choice through the decision confirmation, guided answers, project attach, mode change, cancel, reload-safe lost answer). Bus 1492/1492, webui 538/538, intake+bootstrap 70/70, zero skips; local commit only
2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Rebased 7fd0c3562 and c52218c65 onto c098fa022 (Task 4 core, #802). Migration renamed 3p-3q to 3q-3r, SCHEMA_VERSION 3r; Task 4's 3p-3q left byte-identical; the kind list is the union (Task 4's prd.submitted, prd.activated, prd.invalidated, mode.changed plus question.asked, question.answered, attach.selected). Task 3's own request.mode.console dropped for Task 4's modeConsole; webui maps request-mode-revision-conflict and reads Task 4's draft shape. Bus 1536/1536, webui 540/540, targeted bus 108/108 and webui 28/28, prd-activation 21/21, project prd 6/6, zero skips; not pushed.
- 2026-11-04 — Codex implementation sub-agent — Lifecycle Task 5b (#797/#1839): shared scoped catalogue admission, explicit validated Stack binding, TaskScope/provenance and Git project guards; based on 103168614 plus cherry-picked Task 4 1627e24d7. Final gates 35/107/132/1501 (+32 configuration/tools), zero skips; logs /mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log. No migration, local commit only, never pushed; no live instance touched.
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-4-ui | #797 / Gitea #1839 lifecycle Task 4 Console slice | Exact PRD/requirements panel, existing decision confirmation and owner revoke with route/session/origin/body binding; lead-approved owner draft snapshot-event counter in draftConsole only, no migration/roles/harness/broker/runtime edits. Final bus 1540/1540, webui 554/554 (concurrency 4), targeted Console 42/42, zero skips; evidence /mnt/storage/scratch/tmp/6YKcQI/ includes expected red tests, the interim SSO/CLI timing failures and isolated 14/14 recovery. Linked-worktree queue refusal left to lead; local commit only, no push or live/credential-file access.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 Task 6b | Typed PRD/advance and scoped draft reads; durable continuations, evidence acknowledgements, chief launch refusal/retry and manifest drain/relaunch; migration 3q-3r. New 43/43, focused 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36, extra sandbox 11/11, zero skips; /mnt/storage/scratch/tmp/task6b-final-*.log. Task 4 cherry-pick e41ec8159 from 76207fe48; one local commit only, no push/live changes, queue left to canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase onto b5a58b48b | Dropped e41ec8159; merged Task 3 controls/questions, Task 5b scope/op admission and 6b continuations. New migration 3r-3s retains all fifteen shipped kinds plus advanced; all shipped migrations unchanged. Added question/op integration checks and fixed owned FIFO priority setup timing without weakening assertions. Final new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540, zero skips; per-run counts/log paths /mnt/storage/scratch/tmp/task6b-r2-runs.json. Both append-only logs retained; local rebase only, no attribution trailers or push/live changes; queue remains with canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r3 | #797 / Gitea #1839 Task 6b rebase onto 68bef84fa | Kept both append-only log sides, the Task 4 snapshot-counter behavior and the full 6b continuation changes; 3r-3s migration remains byte-identical to 65588327d. Gates, zero skips: bus 1556/1556, harness 228/228, business 307/307, webui 554/554; logs /mnt/storage/scratch/tmp/nj4n7n/gate-*.log. Local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/console-803-proxy-settings | #803 Model proxy in Settings | Protected /settings/model-proxy and checked /proxy redirect; unchanged proxy API and authority. Red first; proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero skips; logs /mnt/storage/scratch/tmp/console803-logs/. Linked-worktree queue refusal; canonical queue next t3 returned nothing. Local commit only, no push or live/secret access.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question tool | PM-only typed request.question, strict bounded broker arguments, stable-op replay, refusal and unknown-outcome propagation, owner-answer integration and PM duty prose; red-first; harness 234/234, bus 1556/1556, business 307/307, zero skips; logs /mnt/storage/scratch/tmp/W6Jjos/. Local commit only; no push, bus, role JSON, contract or migration changes. Canonical queue next t3 returned nothing.
2026-11-04 | Codex implementation sub-agent, t3/release-811-migration-reader | Tracker #811 | Fixed inline migration-pair reading for release admission and plan; engine-order regression red before fix and green after, mixed and malformed fixtures covered. Repaired the pre-existing rehearsal test fixture's missing Task 5b imports. Final gates: release 226/226, external release 25/25, stack-upgrade 34/34, rehearsal sweep 21/21, zero skips; logs /mnt/storage/scratch/tmp/bqRed6/. Queue next returned nothing in canonical checkout; local commit only, no push or live-instance action.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b preflight | Stopped before implementation: landed Task 6b advances delivery to complete on task closure and creates a chief send_message continuation before artifact evidence, conflicting with Task 7 delivery and atomic notification requirements; owner direction needed. No migration, tests, commit, push, or product changes.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b WIP | After lead ruling implemented and tested a delivery draft; stopped at frozen Console proof/transport hash conflict, with bus 1566/1569 and other recorded gates green; no migration, no commit, no push. Logs /mnt/storage/scratch/tmp/vWMFoL/.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b | Lead-approved proof re-pin, artifact-only cap, real adapter task.close fixture and completed delivery draft. Brief 24/24, bus 1570/1570, harness 228/228, business 307/307, seat 239/239, webui 559/559; zero skips, no migration; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Local commit only, no push.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | #797 / Gitea #1839 lifecycle Task 7b rebase onto 34e9af616 | Cherry-picked 1904c9775 as one local commit; unioned BUILD-LOG and SESSIONS stages 1/2/3 and retained both intake_question and deliver_request in harness tool/bundle expectations. Post-pick checks: webui bus.mjs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d, channel-proof pin 34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692. Bus 1570/1570, harness 234/234, business 307/307, seat 239/239, zero skips. WebUI exact full runs: 562/563 with decide-browser timeout, isolated decide-browser 9/9; second run 562 passed/1 cancelled on tester-cli timeout, isolated tester-cli 2/2; final concurrency-4 run 563/563, zero skips. Logs: /mnt/storage/scratch/tmp/FcQIVV/bus.log, /mnt/storage/scratch/tmp/b3p9Ff/harness.log, /mnt/storage/scratch/tmp/YSkAka/business.log, /mnt/storage/scratch/tmp/4Qkhl5/seat.log, /mnt/storage/scratch/tmp/Zcgo8v/webui.log, /mnt/storage/scratch/tmp/n0o7aV/decide-browser.log, /mnt/storage/scratch/tmp/HIdzOF/webui.log, /mnt/storage/scratch/tmp/gBCdZz/tester-cli.log, /mnt/storage/scratch/tmp/GyMQoN/webui.log. No push.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 Task 7b B1 fix round | Delivery now uses the exact review/verdict/closure accepted by progress; real-close V2 refusal and V1 delivery regressions red then green. Bus 1572/1572, harness 228/228, seat 239/239, webui rerun 559/559, zero skips; initial webui timeout and isolated branch/base passes recorded in BUILD-LOG. Logs /mnt/storage/scratch/tmp/cLYQkO/; local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8 repository acceptance investigation | Stopped under explicit brief-conflict rule: real isolated broker with an approved existing timer snapshot returns an empty owner picker because shipped listProjects/prepareExisting are absent; existing binding also remains request-attachment-unavailable. Diagnostic 0/1, zero skips, retained /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log. No implementation, broad gates, commit, push or live acceptance; append-only before/stop records retained. Linked-worktree queue refusal left with lead.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8a host attachment | Lead authorized filling the missing host gap. Real picker/plan with server mapping revision, locked existing attachment, same-intent snapshot reuse and discovery fallback; stale changes and old-snapshot borrowing refused. Final bus 1580, harness 234, business 307, seat 239, webui 563, all zero skips; retained /mnt/storage/scratch/tmp/UMYC56/. No migration, role/security expansion, install, canonical write, push or live acceptance; local first commit, 8b next.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8b repository acceptance | Three paths, eight separate-process crashes, tracker uncertainty, real typed tracker/Git/review/delivery, offline Chromium and guarded live runner; assistant-origin owner controls and exact PRD cancellation fixed red-to-green. Five older launcher fixture protocols forwarded to real request-host methods. Final gates 4300 tests across bus/CLI/tasks/project/harness/business/seat/WebUI, zero failures/skips/cancellations; full CLI includes opt-in R7 in isolated fake HOME. Earlier CLI 52 failures/1 skip and isolated reproductions retained. Logs/evidence index /mnt/storage/scratch/tmp/tupyBK/. No migration, role/ROUTED/security expansion, install, canonical edit, live acceptance or push. Second local commit; lead-only live checks in BUILD-LOG and packages/cli/live/README.md.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | Gitea #1839 lifecycle Task 7b rebase | Stopped before branch creation: packages/webui/src/bus.mjs differs between requested base 34e9af616 and source 1904c9775 (blob hashes 2d19a538b809a808a53307f049363e62e854ea96 vs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d); pinned-hash guard requires reporting. No cherry-pick, tests, or commit.
2026-11-04 | Lead Codex GPT-6.1 Sol, provider recovery | #815 / Gitea #1843 live MVP 3 /chat failure | Recovered through .53 Window D at 51525a31d/schema 3s; canonical fast-forwarded from b5a58b48b, prior Luna uncommitted append preserved. Diagnosed absent plugins.channel and channel-disabled before project dispatch; typed tester plugins read succeeded. Repair limited to validated channel setting and source error/template regressions; no owner acceptance or secret access.
2026-11-04 | Codex Sol operator, verified/recorded by lead Sol | #815 / Gitea #1843 .53 channel correction | Typed tester setter enabled cli+webui with one channel-only receipt; remaining config unchanged, private backup retained, service PIDs unchanged; operator and lead actual WebUI tester reads HTTP 200. No owner project creation or acceptance; source fix ongoing.
2026-11-04 | Lead Codex GPT-6.1 Sol, context continuation | #815 source repair and owner chat/rooms/threads question | Recovered existing fix-round ownership without restarting or polling the implementer; verified PRD thread/session and Case 2 room semantics against Console and broker. Explained conversation-container versus product-project distinction; broader UX change remains a design discussion. Queue next sage returned historical row 7; continued the current owner-directed #815 assignment.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 Task 2 source fix | Explicit cli/webui Case 1 opt-in and fixed disabled-channel/error/empty-state UI, draft retention and safe retry distinction; targeted chat/room 100, full WebUI 574 and business 307 passed with zero skips. Earlier dependency and concurrent browser timeout results and logs recorded in BUILD-LOG; Console Refresh preserves in-memory drafts, full browser reload does not. Local commit only; controller owns independent review and integration.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 source fix round 1 | Both browser regressions red then green; covering 47/47. Single full WebUI run 575/576, zero skips, unrelated thread-claims browser timeout; that file passed 6/6 isolated. No local commit because applicable full suite did not pass and brief specified one full run. Logs /mnt/storage/scratch/tmp/mosaic-815-r1/; controller to decide further verification. No live or policy changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, controller verification | #815 / Gitea #1843 fix round 1 | Repeated unchanged full affected WebUI gate in completed implementer checkout: 576/576, zero skips/cancellations, required editor rebuild passed; candidate source/test hashes unchanged. Preserved prior 575/576 and isolated 6/6 results; local commit and independent re-review next. No live change or acceptance claim.
2026-11-04 | Lead Codex GPT-6.1 Sol, reviewed-source integration | #815 / Gitea #1843 | Re-review approved I1/I2 at a1ba9cd56. Integrated reviewed source into refactor with byte-preserving append-only records; canonical verification and authorized push next. Queue next sage named unrelated historical row 7; continued current owner assignment. No live operation or owner acceptance.
2026-11-04 | Lead Codex GPT-6.1 Sol, #815 integration verification | #815 / Gitea #1843 | Canonical WebUI 576/576 and factory 8/8, zero skips/failures/cancellations; independent R2 approved; source exactly matches a1ba9cd56 outside preserved append-only records. Existing plan authorizes commit/push to refactor. No source deployment or owner end-to-end acceptance.
2026-11-04 | Lead Codex GPT-6.1 Sol, design controller | #816 / Gitea #1844 Projects workspace UX | Recorded owner requirements and design-only boundaries; complementary GPT design work, wireframes, then visual images authorized. Current task overrides historical queue row 7. Mobbin connection pending owner setup; no application code or live changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, design synthesis | #816 / Gitea #1844 | Read autonomous-conversation report and circulated it to running navigation/dashboard designers for joint alignment. Primary assistant work card, honest room audience and secondary session controls are draft recommendations; no product code or live changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, design coordination correction | #816 / Gitea #1844 | Dashboard designer accepted peer-input steer. Navigation steer was not delivered because no turn was running; no new navigation task started. Earlier circulation entry refers to attempted navigation delivery; later synthesis will use its completed report.
2026-11-04 | Lead Codex GPT-6.1 Sol, wireframe synthesis | #816 / Gitea #1844 | Combined navigation and autonomous-chat first passes into an isolated interactive design artifact. Desktop/mobile previews and local dashboard/disclosure/room-recipient transitions checked; dashboard metrics provisional pending peer report. Generated-image stage and Mobbin refinement remain ahead; no application implementation.
2026-11-04 | Lead Codex GPT-6.1 Sol, design synthesis and image authoring | #816 / Gitea #1844 | Reconciled navigation/autonomous-chat/dashboard reports; delivered v2 interactive wireframes and three built-in generated screen concepts. Selected files/hash manifest/exact prompts in projects-816 design pack. Preview/state checks apply only to artifacts. Mobbin refinement and owner design feedback remain; no implementation. Vikunja updated; Gitea wrapper identity refusal preserved without fallback.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel-lane coordinator | #816 UX / #797 MVP 3 | Started independent UX Lead and MVP 3 Lead sessions in separate T3 worktrees; froze reference assets and ownership boundaries. UX Mobbin search confirmed, functional acceptance recovery underway. Root owns integration and independent assessment; no application change in this setup phase.
2026-11-04 | Lead Codex GPT-6.1 Sol, functional-lane coordination | #797 MVP 3 / #816 design | Reviewed fresh .53 readiness and bounded message-driven repair plan; offline author/review continues without design overlap. Healthy runtime is not current inference or owner acceptance. Shared pin/no-capture metadata with UX while preserving historical screenshot uncertainty; no live action or source integration.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel coordination | #816 UX / #797 MVP 3 | Confirmed research handoff and issued score-free first wireframe refinement brief; candidate/reference preservation intact. Independent functional source repair continues; fixture proof does not establish real inference/owner acceptance. No live action or source integration.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel coordination | #816 UX / #797 MVP 3 | Verified independent baseline assessments and retained evidence limitations; original concepts preserved. UX wireframe freeze/review and MVP full-package gate/exact-candidate review continue in separate lanes. Queue next sage still names historical row 7; direct owner assignment governs. No source integration, publication or live operation.
2026-11-04 | Lead Codex GPT-6.1 Sol, skill coordinator | Vikunja #818, related #816 / Gitea #1844 | Owner requested reusable UX/UI improvement skill. Charter prepared and GPT author launched in a T3-isolated checkout for skills/ms-ux-improvement only, with behavioral validation and fresh independent review required. UX/MVP ownership unchanged. No application changes, publication, skill activation or live actions.
2026-11-04 | Lead Codex GPT-6.1 Sol, MVP review coordination | #797 / Gitea #1839 | Read R1 CHANGES report and accepted bounded recovery backlog finding for sole-owner correction and fresh R2. Final full-gate candidate identity required; original run and all failures retained. UX and reusable-skill work continue independently. No source integration/live action.
2026-11-04 | Lead Codex GPT-6.1 Sol, overnight coordinator | #816 UX / #797 MVP 3 / #818 skill | Owner reaffirmed full send toward morning design approval and MVP testing; design application follows MVP activation. Relayed targets to isolated leads, retained review/test/deployment gates and confirmed missing attributed Git identity. Independent work continues; no live, publication or acceptance claim.
2026-11-04 | Mos, lead T3 coordinator | #816 / #797 / #818 | Owner confirmed identity Mos; existing per-identity credential verified through wrapper read and attributed issue update, new skill issue #1845. W1 all140 manifest entries/structural check and MVP R2 all14 file hashes verified. Root uses per-command identity and agent commit attribution; no shared config/fleet change or live action. Independent design assessment chartered; MVP fresh review/final gates continue.
2026-11-04 | Mos, lead integration preparation | #797 / Gitea #1839 | Read exact R2 APPROVE, verified candidate/source preflight and recorded integration plan while final gates run. R1 recovery finding closed; unchanged WebUI failure retained with affected-suite retry pending. Foundation CI config absent outside v1; no invented CI gate. No canonical application source or live change.
2026-11-04 | Mos, gate evidence coordination | #797 / Gitea #1839 | Read supervisor/log evidence after143 termination; reported child absent and TAP summary green but actual exit receipt missing, so gate remains unverified. Cause unestablished; sole gate owner retains bounded direct-node retry. No process action, duplicate suite or frozen-source change.
2026-11-04 — T3 MVP 3 independent lead — #797/#1839 functional acceptance diagnosis in t3/mvp3-lead-797 at e9271841f — in progress; coordinator retains integration/protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair | Started isolated author work at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; queue refused linked worktree; evidence under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/; no canonical or protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair | Isolated uncommitted candidate at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; final scoped gate 2610/2610, zero failures/cancellations/skips; retained RED/GREEN, superseded/interrupted runs, and exact patch under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/. Independent review and coordinator integration remain; no live or protected operation.
2026-11-04 — T3 MVP 3 independent lead — #797/#1839 R1 bounded-recovery correction and frozen replacement verification — source repair4/4GREEN; fresh R2/full-package gates pending, no commit/integration/live authority used.
2026-11-04 | Mos, root integrator | #797 / Gitea #1839 | Applied exact R2 non-log source, unioned append-only histories, verified all12 file hashes and8 JS syntax/whitespace checks. Independent R2 APPROVE and4333 matching local cases retained; initial author Terra and lead-correction Sol attribution preserved. Native Git lineage refusal recorded; no transport bypass, publication or live action.
2026-11-04 | Mos, root delivery coordinator | #797 / #818 | Committed reviewed exact MVP source locally asbd8120318 after4333 required local cases and R2 approval; source12/operational/schema checks match. Publication blocked by native helper anonymous-credential-refused, distinct from working Mos API access. Skill candidate02 verified and fresh independent review prepared. No bypass, push, installation, activation or live action.
2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill, chartered isolated checkout | Started baseline behavioral evidence; author-only handoff, independent source review pending; no protected actions.
2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication.
2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes | Isolated author work on fix/mvp3-relay-797 at 05fdfef9ea1fdddf58c725c04f229ca8559fc054; queue refused linked worktree; local commit only, no live or push authority. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes author handoff | Four bounded fixes; F2 red/green retained; final bus, harness, business and direct-importer gates 2657/2657 with zero failures/cancellations/skips. Local commit only; independent review remains. Full evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 trusted coordinator context | Chartered local branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36; isolated config-to-bundle-to-broker correction, no push or live authority. Queue refused linked worktree; canonical read returned nothing. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 author handoff | Trusted configured-coordinator discovery with offline arbitrary-key integration; required 321/321, related 124/124, final touched bundle 24/24, corrected reviewer acceptance probes 13/13. Original probe preserved with obsolete equality assertion documented. Local commit only; independent review remains. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md.
2026-11-05 | implementation child | #797 MVP 3 planning coverage at 05fdfef9e, branch fix/mvp3-planning-797 | scalar coverage guidance and actionable PM notice ready for review; 228 tests pass, no skips; coder proxy-stall diagnosed read-only, D3–D5 reported; queue refused linked worktree; local commit only, no push/merge/runtime writes.
2026-11-05 | Codex implementation child | MVP 3 #797 D2 coder-stall fix on fix/mvp3-coder-stall-797 from 05fdfef9e | Claude private streaming activity marker + answer-only stdout; Pi model-event coverage; silent/interrupted controls preserved. Final Node 24.22.1 full 28-file gate: 376 pass, 0 fail/skip/cancel. Queue refused linked worktree; no workaround. Local commit only, no push/stash/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md; independent review remains with lead.
2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F1 on fix/mvp3-coder-stall-797 from 1bd007739 | Preserve complete failed Claude result stdout and exit status; module-declared model activity disables synthetic stdout marking while Pi/worker/mock fallback remains. Real localhost HTTP 400 regression fails baseline and passes fix; final Node 24.22.1 gate 414/414 across 31 whole files, no skips. One local commit only; no push/stash/forge/live actions. Queue refused linked worktree without workaround. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; independent review remains with lead.
2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F2 on fix/mvp3-coder-stall-797 from 8094901bc | Private helper completion pipe supplies exact fixed diagnostics for SIGKILL/load/runtime failures while complete CLI errors preserve text bytes/status, with no synthetic activity or new session. Baseline 20 pass/4 intended fail; final Node 24.22.1 sequential 31-file matrix 420/420, no skips. Direct, managed and read-only bubblewrap controls pass; helper digest pin updated. One local commit only; queue refused linked worktree without workaround; no push/stash/forge/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md; independent review remains with lead.
2026-11-05 | Codex ui797 implementation child | #797 isolated OIDC/TLS, browser CLI and campaign budget support from 05fdfef9e on feat/ui797-support | Author implementation/tests underway in lead-chartered worktree; queue refused linked worktree, no canonical queue writes or protected actions.
2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits.
2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md.
2026-11-06 | ui797-support-fix-r3 implementation sub-agent | #797 two P1 corrections above 19e6cb0f1 on feat/ui797-support | Mandatory browser network namespace/private proxy relay and independently bound unavailable-ledger halt with persistent serve reassertion; final sequential whole-file suites 61 pass, 0 fail/skip/cancel; all R1/R2 probes rerun, zero forbidden TCP/UDP, storage halts counted and latch preserved, zero owned processes/listeners. One BUILD-LOG entry and packages/cli/live changes only; local commit authorized, independent review pending. Queue refused linked worktree; no push/stash/forge/model/.53/host network-DNS-trust or unrelated-session action. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-report.md.
2026-11-06 UTC | ui797-support-fix-r4 implementation sub-agent | #797 review corrections on feat/ui797-support above 905d313cd | Minimal browser mount/environment boundary; independent config-byte proof and retained binding keep halt on sidecar damage; normal-path campaign hash/epoch agreement; unconditional namespaces and HTTPS 17443/oidc docs. Baseline failures retained; six controls corrected; whole files 112 pass, 0 fail/skip/cancel; five retained probes exit 0, zero forbidden traffic, no owned survivors/listeners, TMPDIR removed. Five support paths, one appended BUILD entry and this line; prefix bytes verified. Queue refused linked worktree; no queue/forge/push/stash/model/secrets/.53/live or unrelated-session action. Local author candidate for independent review. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R4 P1 fix on feat/ui797-support above fce831c05 | Conflicting ledger targets now need owner corroboration or are replaced by the owner-verified pin, else refused; 4 regressions fail on r4 and pass after; whole files 116 pass/0 fail; reviewer probe 0 wrong halts. Local commit; independent r5 review next. No push/stash/.53/live action.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R5 P1 fix on feat/ui797-support above 85e1ec5d1 | Pin must name its own campaign's and the owner's target; serve freezes startup paths and retains the ledger path; running service corroborates by its unchanged startup ledger. Owner business/config retarget refuses without retained evidence (tradeoff recorded). 4 regressions (3 fail on r5); whole files 120 pass/0 fail; reviewer probes 0 wrong halts. Local commit; independent r6 review next. No push/stash/.53/live action.
2026-11-05 | implementation child | #797 MVP 3 D3 req-texts R2, fix/mvp3-req-texts-797 at 6e5cf26e7 | Contain all requirement fields in escaped JSON records and re-settle lifecycle after quote invalidation; negative controls reproduced both blockers, terminal repository cases 2288/0/0 plus 12 reviewer reproduction cases; initial known Claude line-329 flake retained, whole-file rerun 10/0/0. One local commit only, ready for independent review; report /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-fix-r2-report.md; queue refused linked worktree, no bypass/push/live actions.
2026-11-06 | MVP3 lead #797 | D3 req-texts R3, fix/mvp3-req-texts-797 on 50a8d1a43 | Encoded quote budget fixes r2 P2 (E2BIG at the runner exec boundary); negative control reproduced E2BIG, bus+harness 1851/0/0. Local commit only, awaiting independent r3 review.
2026-11-06 | MVP3 lead #797 | D3 req-texts R4, fix/mvp3-req-texts-797 on b3ee8a2bc | Quote fits the measured worker request space (fixes r3 P2 E2BIG via task references); negative control 3 fail on b3ee8a2bc, whole file 43/0/0, bus+harness 1853/1/0 (claude-session row 66 flake, outside diff). Local commit only, awaiting r4 review.
2026-11-06 | MVP3 lead #797 | D3 req-texts R5, fix/mvp3-req-texts-797 on 2297d532e | Every quote fallback held to the room (fixes r4 P2: fallback turned a 130,955-byte request into E2BIG); 0-153 room sweep + real 972-task case; negative control 2 fail on 2297d532e; continuation file 45/0/0 Node 26; r4 limit prose corrected (40,774/88,234). Local commit only, awaiting r5 review.
2026-11-06 | mvp3-archive-child (#797 lane) | feat/mvp3-project-archive from 05fdfef9e: owner-only project.archive/unarchive (schema 3s→3t project_archives journal), project.list includeArchived, archived-project refusals on thread/room sends, Console archive UI (new project-archive files), CLI project archive/unarchive/list --archived, restore-guard lists | bus 1601/1601 (Node 24; Node 26 pre-existing socket stall), webui 582/582, plugins 132/132, cli 1191/1194 with two pre-existing load flakes (row253 C4 timing, land_work; both pass isolated, base full-suite fails four of the same family); committed locally, not pushed; report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r1-report.md
2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action.
2026-11-06 | mvp3-archive-r3-implementer (#797 lane) | feat/mvp3-project-archive-r3 from 1c33c342b: fix final launch admission and preserve originating chat through bootstrap, PRD and task admissions; shared-repository note and regressions | Node 26: regressions 6/6, bus excluding socket 1614/1614, WebUI 583/583, corrected whole release file 226/226; socket timeout and known adapter-127 failure remain ungreen, with all intermediate failures retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. Local commit only; limited independent review pending; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 MVP3 project archive r4 | Applied reviewed merge 6a655a5cb and two WebUI fixes; Node 26 WebUI 644/644, bus without socket file 1642/1642, harness 277/277, CLI 1198 pass / 1 skip. Socket timeout reproduced on 5255e4e; original failures and comparisons in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md. Local candidate for independent delta review; no push or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r5 WebUI fixes on 37f38d8e1 | Unknown archive state suppresses unguarded writes, guarded attach maps to 409; final Node 26 WebUI 649/0/0/0, five exact-baseline behavioral negatives, real authenticated attach refusal. Initial diagnostic regressions corrected; inventory, all evidence and limits in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 UTC | ui797 stall-guard implementation sub-agent | #797 two fixes on feat/ui797-stall-guard from 5df42478b | Adapter event monitoring for first model output and typed launch refusal backoff, with base/fix evidence and package gates under /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-evidence/; linked-worktree queue refused. Local commits authorized; independent review pending. No push, stash, protected host, live-instance, role, policy, schema or config change. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-report.md.
2026-11-06 UTC | ui797 stall-guard review-fix implementation sub-agent | #797 three corrections on feat/ui797-stall-guard-r2 from 3e754c96a | Adapter stdout parity, signal-specific child-death receipts, instance-running-only cooldown; Node 26 gates and negative controls recorded in /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md. Local commits only; queue refused linked worktree; independent review pending. No push, merge, deployment, live-host contact, model call, secret, role, policy, contract or schema changes.
2026-11-06 UTC | ui797 stall-guard integration implementation sub-agent | #797 integration of 5255e4e74 into 6b2c925b9 | Reconciled Claude/Pi activity sources, retained cooldown and signal receipts; final Node 26 harness 309 pass, CLI 1197 pass/1 base-reproduced skip, six guard E2Es within 11 pass, bus 1615 pass plus project-files 5 pass/1 timeout also on exact BASE; earlier row-66 timing failure and all comparisons retained. Local merge candidate for independent review; no push, deployment, real model call or protected-host contact. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md.
2026-11-06 UTC | supervisor-3t implementation sub-agent | #797 closed 3s/3t supervisor guard and recovery guidance above 37f38d8e1 | Real-store regression red on BASE; sequential whole-file gates 125 pass, 0 fail/skip, evidence in /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/. Queue refused linked worktree; one local commit for independent review, no push, deployment or live access.
2026-11-06 UTC | resume-chief implementation sub-agent | #797 F-resume-chief on fix/mvp3-resume-chief from 13f81354e | Host-gate receipts no longer consume chief attempts; three behavioral base controls, touched file 12 pass, bus 1642 pass, serial CLI 1207 pass/1 base-reproduced skip. Initial concurrent timing and Claude-wrapper failures, baseline comparisons, and report retained under /mnt/storage/scratch/mosaic-lead/mvp3/. Queue refused linked worktree; local commit only, independent review pending; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r6 WebUI fixes on 8c3f48113 | Known-archived unguarded writes suppressed in controls and handlers; final Node 26 WebUI 654/0/0/0, five new tests and changed navigation assertion fail on baseline as expected, real Store/Broker guarded-refusal probes 4/4. Full inventory, diagnostic iterations and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r7 WebUI correction on 51892cf95 | Retained archived state fences settings, thread and room writes across failed refresh; final Node 26 WebUI 658/0/0/0, four exact-baseline behavioral negatives, 1440px diagnostic timeout and candidate/base isolated reruns documented. Room Send still requires a successful membership read. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r8 WebUI correction on 5bf07f35f | Same-thread refresh retains resolved live archive state; switch and failed-lookup withdrawal retained. Exact-r7 behavioral negative reproduced; Node 26 archive browser files 11/11 and full WebUI 660/0/0/0. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | mvp3-lead-797 (Claude, #797 lane) | project archive r9 WebUI fix on 4c85d3784 for the r8 review P1 | A same-thread refresh that resolves archived or unknown withdraws request-panel and unguarded thread writes before the session read. r8 negative fails for behavior; reviewer probe 4/0; focused 12/12; whole WebUI 661/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/. Local candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | Terra implementation sub-agent | #797 write_record JSON object compatibility on fix/write-record-object from 13f81354e | Strict parsed JSON objects and scoped agent-visible validation details; exact-base seven-test negative, Node 26 serial gates and base-reproduced CLI skip recorded in /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Queue refused linked worktree; local commit for independent review, no push or deployment.
2026-11-06 UTC | Codex implementation sub-agent | #797 Files heading and remount polish on files-polish from 4cc3d05b3 | Single caller heading and fenced sign-out-cleared DOM cache; Node 26 authorized browser gates 91/0/0/0, exact-base behavioral controls and fixture correction recorded in /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Queue refused linked worktree; canonical queue reported nothing for this seat. One local candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | files-polish r2 on 86383a9d1 for Sol r1 F1/F2 | Per-key commit order fences live paints and caches the newest completed result; reviewer probes ported; r1 negative 11/2, w15-files 13/13, caller browser gate 93/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
2026-11-06 UTC | Codex implementation sub-agent | #797 roundA-fix on 6b84fe136 | Named per-task push/review gaps, shared the check with coder continuation and stopped missing-request thread polling; owner authorized bounded harness socket reason forwarding. Exact-base behavioral negatives and Node 26 six-file gate 142/0/0/0 retained in /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/; local candidate for independent review, no push or deployment. Queue refused linked worktree.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r2 on 97d670da9 for Sol r1 P2/P3 | Missing-request memory now also clears on thread open and on an observed request state, is pruned to retained rows, and keeps AbortError; event-only open without navigation documented as a remaining limit. Negative 0/2 on 97d670da9, webui gate 40/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r3 on 16384bb12 for Sol r2 P2 | A request state observed before an in-flight 404 settles now fences that miss via a per-id generation. Negative fails on 16384bb12, webui gate 41/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r4 on 9d3eb9e11 for the Lead's red webui push gate | Workspace 404s no longer record a request miss; the r3 held-404 test waits for its refresh reads. request-browser 24/0, whole webui 679/0/0/0; r1-r3 gate omission of request-browser recorded in BUILD-LOG. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-push-gate/r4-*.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | #1847/#1848 fix row: Terra build 4048e0f60, Sol r1 CHANGES, lead fix c04529c7b | Echo auto-replies and duplicate continuation notices stopped; halted-restart and stale-launch review findings fixed with regression tests. Gate touched 123/0, bus 1648/0 (project-files excluded, hangs on base), harness 318/0, webui 679/0 on rerun. Not pushed; Lead pushes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/step14/round-b/fix-1847-1848-gate/.
@@ -15,6 +15,7 @@
"bus.tester": true
},
"plugins": {
"channel": ["cli", "webui"],
"harness": ["claude-code"]
},
"roles": {
+7 -1
View File
@@ -377,9 +377,15 @@ place the correct project file and repeat the check above before continuing (Duc
line holding `--`. Root writes it at the unit render, from the values the go names. The stack's user's
install never writes it: that user can't write `/etc`, and root reads nothing that user wrote. It must be root's, with no group
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
`--copy` (exactly once), `--node`, `--profile`, `--claude`, `--pm` and `--proxy`. `stack-upgrade.sh` changes only the
`--copy` line, and only after confirm passes.
For a hub installed with `--proxy`, keep that bare flag on its own line before `--`; it takes no value.
Upgrade and rollback require the proxy, bus and Console units to name the same copy. They stop the Console,
then the bus, then the proxy, re-render all three onto the selected copy, and start the proxy, then the bus,
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
@@ -0,0 +1,48 @@
# Request intake and product delivery — proposed PRD addendum
Status: **proposed exact implementation text for owner-authorized behavior**. Prepared against `7e849a7b0b635d34950a5c5c2e4b78c4238fe651` for Vikunja #797 / Gitea #1839. Jason authorized the staged request-to-product work and GPT agents; this does not assert that the following exact version text has already received user approval. The approved `docs/prd/mosaic-stack.md` remains unchanged. Assign the next PRD version and record its exact approval only during the owning integration process.
Source design: `docs/plans/2026-11-03_request-to-product-design.md`. Implementation and acceptance: `docs/plans/2026-11-03_request-lifecycle-implementation.md`. Stage 1 implements existing REQ-WEB-2 and needs no duplicate requirement here. New prose uses “user”; current technical names such as `human.input`, `business.human` and `writeHumanFile` remain compatible until the separately tracked #798 identifier migration.
## Proposed additive text
Insert the following subsection after Projects. Requirement IDs below are proposed additions; check for collisions at integration before assigning the approved version. They describe Stack's lifecycle capability. A pomodoro product uses its own requirements and must never cite these as its product specification.
### Request intake and product delivery
- **REQ-INTAKE-1.** A user request can enter discovery without an implementation task or an approved product requirement. The stack preserves the original input event, message text, owner, business and conversation context in a durable intake record. If the product context is absent, it offers three choices through the Console: “New project — autonomous,” “New project — guided,” and “Add to existing project.” Each available decision option has a working answer control under REQ-WEB-2 and REQ-DEC-3. Cancellation retains the request and creates no work. A missing or insufficient PRD starts discovery or revision; it never causes the stack to invent a task, requirement ID or product attachment.
- **REQ-INTAKE-2.** Before a user confirms a new project, the stack displays its proposed name, original intent, mode, code source and revision, local code and records locations, repository and fork arrangement, tracker association and responsible roles. The confirmation binds that exact resource plan and its current organization revision. The first supported route may reuse a declared software-factory repository and an installed tracker board in its configured tree, while creating a distinct product project, separate code checkout, local records repository and local working repository. Sharing an upstream or tracker board never shares requirement authority or grants publication rights. Existing-project selection lists only authorized software projects and confirms the exact association; a chat grouping alone is not a provisioned business project.
- **REQ-INTAKE-3.** `project.bootstrap` is a protected action that remains a user hard wait under REQ-WAIT-3. An authenticated user selection and confirmation of the displayed plan authorizes only that bootstrap, once. Its typed executor may add exactly the approved project mapping and create the approved local resources within the configured managed data area. Ordinary organization writes remain unable to alter project mappings. The executor rejects stale revisions, collisions, links and overlapping ownership, validates the full business and project configuration, and records each partial effect for recovery. It makes the finished project available to broker validation, tracker association and subsequent role launches before reporting readiness. No reusable project-creation mandate is required or introduced by this increment.
- **REQ-INTAKE-4.** The user's intent specification remains distinct from the product PRD. The protected bootstrap executor may commit the exact original intent displayed and confirmed by the authenticated user to `spec/north-star.md`, with that confirmation recorded as provenance. Any generated or altered intent must first be displayed and explicitly confirmed by the user. Agents cannot use this executor to author an unconfirmed specification. Agents write assumptions, alternatives, PRDs and requirements as attributed agent-authored project records under REQ-PROJ-1 and REQ-PROJ-2.
- **REQ-INTAKE-5.** Autonomous mode lets agents make and record product choices within the confirmed intent and current role authority. A PM submits the exact product PRD and requirement set for a separate authorized review, initially by the business's technical arbiter acting as CTO through `prd.approve` with a current confirmed spec citation. The author cannot approve its own submission. Guided mode asks concise questions through the Console and returns the exact draft and requirement set to the user for approval. Free-text answers are attributed inputs, not implicit PRD approval. Both modes preserve the existing decision routing, identity checks and hard waits; product mode never approves spend, account creation, credentials, external messages, protected or remote pushes, deployment or other protected actions.
- **REQ-INTAKE-6.** Stack code activates a product requirement snapshot only after verifying an approval bound to the business, product project, PRD version, records commit and blobs, requirement contents, current intent spec and mode revision. It records authors, reviewer or user approver, decision and resolution evidence. An agent-written accepted status or document is not activation. Editing approved content requires a new approval. A changed intent, revoked approval or mode change is checked before the next action; superseded and revoked snapshots remain immutable historical evidence. Changing mode or revoking approval is an authenticated user control and does not retroactively rewrite task or run history.
- **REQ-INTAKE-7.** Requirement discovery and tracker task creation consult the same approved project snapshot. A product task identifies its business, product project, snapshot, requirement ID and original request, and its evidence includes source PRD version and commit. A requirement from a draft, wrong product, unapproved or revoked snapshot, or ambiguous context is refused. The selected tracker project must be installed in the business's authorized tracker tree. The built-in Mosaic Stack catalogue remains available only for the explicitly identified Stack project and compatible existing callers; it is never a fallback for another product. Local code pushes, reviews and delivery remain bound to the task's product project.
- **REQ-INTAKE-8.** The intake record tracks selection, bootstrap, discovery, PRD review, planning, implementation, verification, review and delivery, with an exact waiting reason and continuation whenever it cannot advance. Pre-task decisions remain nonblocking in the task system; the intake record owns their wait. PM staffing requests may name an intake request before a task exists. The chief launches existing authorized staff through the normal launch, model, processing and limit checks. Decision resolution, completed setup and role progress resume the responsible role automatically. Neither messages nor typed tools grant extra authority, and the assistant's existing messaging boundary remains intact. The user does not act as a messenger between agents or run decision commands to advance the workflow.
- **REQ-INTAKE-9.** Every lifecycle transition checks the expected request revision. Each effect has a durable operation identity and payload binding. Repeated input, duplicate answers, repeated notifications and process restart must not duplicate projects, PRD activation, tracker tasks or artifact delivery. An uncertain external write is reconciled against recorded operation evidence before further effects. An ambiguous or unprovable outcome remains an explicit wait; absence of a read result alone does not authorize another write. Restart recovery retains prior evidence and resumes incomplete continuations, including a role that died after receiving its message.
- **REQ-INTAKE-10.** Delivery means a usable artifact produced by real implementation and verification and approved by a separate reviewer for the exact latest work commit. The evidence links original request, product intent, approved PRD and requirements, tracker task, implementation commit, verification record, review request and verdict, and delivered artifact hash. The stack exports committed bytes from its trusted working repository and offers an authorized download. Product HTML does not run inside the privileged Console origin. The first supported artifact is self-contained offline HTML. Reviewed delivery is distinct from protected landing, remote publication and deployment and does not require those actions.
- **REQ-INTAKE-11.** Missing resources or authority produce one actionable setup wait naming the service, instance, resource or scope, available protected executor and condition for automatic resumption, without exposing secrets. Existing credential and account hard waits remain. Reusing configured factory resources is sufficient for the first supported delivery; creating new external repositories, tracker projects or accounts is not silently implied. An unsupported setup executor is reported explicitly, and the request cannot be called delivered while its required setup is incomplete.
## Proposed acceptance text
1. An isolated configured instance accepts the original single-file pomodoro request and an initial autonomous project selection/confirmation. Separate PM, technical approver, coder and reviewer roles produce an approved product PRD, a scoped real tracker task, working offline HTML, verification and independent review, and a downloadable artifact with requirement/PRD/task/commit/review evidence. No user message relay or command execution is required. Proposed 25-minute work, 5-minute break, start/pause/reset and phase-end cue defaults are product decisions recorded and approved in that product PRD, not attributed to the user's original request unless its actual text says them.
2. Guided integration exercises questions, attributed text answers, a rejected/revised draft and exact user approval before task creation. Attachment integration covers an existing sufficient snapshot and an existing project that needs discovery/revision, with no duplicate project or borrowed requirement authority.
3. Real process termination and restart against persistent stores exercise partial bootstrap, approval-before-activation, tracker-create response loss, review completion and export-before-notification. IDs and effects remain unique; uncertainty is visible and no historical evidence is rewritten.
4. Deterministic role fixtures exercise real broker, tracker adapter, project repositories, typed tools, launch checks, commits, review protocol and signed-in Console endpoints. Their results are labeled fixture integration. A separate actual-model run in an authorized isolated instance supplies live role/host evidence; fixture success alone is not claimed as live acceptance. Production deployment and external setup require their own concrete authorization.
## Integration notes outside the proposed PRD text
This increment requires a narrow project-map writer and new `project.bootstrap` action, intentionally kept out of `ROUTED` and every role authority list. It does not need a generalized approval mechanism or expanded PM launch rights. Existing `prd.approve`, `record.write`, task, local working-push, review, message and chief launch authority provide the functional route. Duty contracts and typed adapters must expose it consistently; approved role authority files are unchanged by this planning task.
Existing Gitea `credential.mint` can handle a later concrete product grant/fork need. Existing Vikunja role scopes and `credentials.issue` do not automatically create tracker projects or mint tracker identities; the first route reuses an installed board and configured identities. Missing foundation resources remain protected setup blockers, not an instruction-only substitute for delivery.
The source design does not contain the verbatim original pomodoro input/event. Final original-request acceptance needs that provenance from the lead's incident evidence. A reconstructed request can be labeled representative for fixture development, but cannot be labeled the original acceptance run.
@@ -0,0 +1,264 @@
# Request Lifecycle Implementation Plan
> **For agentic workers:** Use `superpowers:executing-plans` to implement this plan task by task. Steps use checkbox syntax. This planning assignment authorizes no implementation, delegates or review runs; a later implementer follows the lead's execution assignment.
**Goal:** Carry an unattached user request through project selection, discovery, approved product requirements, real implementation, independent review and an openable artifact without user message relay.
**Architecture:** Extend the existing broker's append-only SQLite evidence and project records, with a small fixed lifecycle reducer and durable effect receipts. Reuse the configured software factory's repository and installed tracker board, existing role sessions, local working repositories and review protocol. There is no new workflow service, reusable creation mandate or general authorization framework.
**Tech Stack:** Existing Node.js ESM, `node:sqlite`, local Git, Vikunja adapter, typed harness tools and signed-in Console. Preserve the pinned harness and existing sandbox/processing/launch controls.
**Spec:** `docs/plans/2026-11-03_request-to-product-design.md`, stages 2–4; owner continuation and this assignment narrow the first supported route. Base: `7e849a7b0b635d34950a5c5c2e4b78c4238fe651`. Tracking context: Vikunja #797 / Gitea #1839. The separate `2026-11-03_request-intake-prd-addendum.md` is proposed exact implementation text, not an assertion that a new PRD version was approved.
## Purpose and boundary
This is a builder check and executable implementation plan, not evidence of delivered product behavior. Stage 1 is independently owned: consume `decision.preview.console`, `decision.resolve.console` and `/api/decisions/:id/confirmation|resolve` after integration. Do not redesign its option transport, grant summaries or confirmation. Intake-specific forms and reads belong here; generic decision buttons do not.
The first supported new product reuses a user-selected, already declared factory code source and an already installed tracker board in that business's configured tracker tree. It creates a distinct business project key, separate local code checkout, separate local records repository, chat-project association and local working repository. Sharing a tracker board or upstream does **not** share product requirement authority. Product tasks explicitly name their product and approved snapshot. This uses the design's authorized-resource reuse route and avoids requiring new external repositories, accounts, tracker projects or broad service credentials for an offline HTML result.
A resource plan displays the source project/commit, upstream and existing fork state, tracker root/selected board, paths, roles, mode and exact original intent before confirmation. Choosing autonomous or guided and confirming that displayed plan authorizes that one bootstrap. Reusing an existing project has a picker and confirmation bound to its current mapping. Unsupported resources produce a precise setup wait; silently substituting Stack requirements or a different board is forbidden.
## Global invariants
- Discovery, PRD drafting and their decisions have no fabricated task or requirement. Pre-task decisions use existing `blocking: false`; the intake journal owns the wait.
- Preserve the original `human.input` event and message bytes. That is a current technical identifier; new prose uses “user.” Identifier migration belongs to #798 / Gitea #1840.
- Only authenticated owner controls can select a mode, approve bootstrap, confirm generated intent or change mode. First support is the existing business owner Console identity; delegated-user creation is not implied.
- Original intent becomes `spec/north-star.md` only through the protected executor of the exact user-confirmed plan. Agent assumptions stay in agent-authored PRD records. Existing `writeHumanFile`/`business.human`/`confirmed` identifiers remain compatible.
- Broker reads and tracker creation use **one** project-scoped approved snapshot. Draft records, a JSON `status: accepted`, a bare requirement ID or a chat project alone confer no authority.
- Autonomous PRD approval uses a distinct configured technical arbiter, initially CTO; guided approval uses the user. Bind the decision to exact PRD/requirement content, current spec and mode revision. No PM self-approval.
- Existing role grants, messaging graph, processing state, launch revocation, quotas, tool limits, credential scopes and all hard waits remain enforced. A new `project.bootstrap` action stays user-gated; do not add it to `ROUTED` or any role authority list.
- No protected push, remote publication, merge, deployment, external message or spend is needed to deliver the reviewed local HTML. Such later actions keep their existing gates.
- Effects reserve an operation key before execution; a retry reconciles evidence. Append corrections and supersessions; never rewrite run records, events, decisions, prior snapshots or receipts.
- Managed product paths are under the configured `dataRoot`; reject links, overlaps, collisions and unrelated partial contents. Existing projects keep their paths. No new repository-root files.
- The original planning assignment changed only its two planning artifacts. A later execution assignment authorizes only the named task and its checks; it does not authorize production setup or unrelated authority edits. The controller records integration and session evidence centrally.
## Review focus
1. Two requests sharing a tracker board or the same `REQ-*` spelling must never borrow one another's product authority (Task 5).
2. A restart after an external task create but before its receipt must recover the existing task, including a subsequently closed task, without a second POST (Tasks 5 and 8).
3. A valid selection followed by a changed organization, intent or mode must not execute stale bootstrap/approval work (Tasks 2 and 4).
4. A delivered message followed by a dead role session must not strand the continuation; launch refusal must stay visible and resume only through allowed launch paths (Tasks 6 and 8).
5. An approved commit followed by a new push, or an artifact containing links/traversal, must never export unreviewed or host-local bytes (Task 7).
## Builder findings and first-route decisions
| Existing seam at base | Consequence and chosen integration |
| --- | --- |
| `broker.mjs` `project.create` only inserts a chat grouping with optional `repo` | Create/associate the business mapping and records/code resources separately; retain `project.create` semantics. |
| `organizationWriter().write` checks `organization-projects-protected` before and after serialization | Add a dedicated exact-add operation; do not introduce an escape flag into ordinary organization writes. |
| `runtime.mjs` `fixedAtStart`/`sameFixed` rejects changed roots on reload | Admit only completed, receipt-backed managed project additions; retain restart/refusal behavior for arbitrary roots/credential/bot changes. |
| `cli/src/config.mjs` `trackerFor` refuses two project files with `tracker.project` | Keep the single factory tracker root. Product `.mosaic/project.json` omits `tracker.project`; the approved resource binding selects an installed board from that root's tree. |
| `tasks/src/verbs.mjs` and `broker.mjs` separately call `loadRequirements()` | Replace product lookups with a shared resolver. Keep the static loader only for the explicitly bound legacy Stack catalogue. |
| `staff.request` requires `task_ref`; PM has no launch authority | Add an intake alternative to that typed request, still PM → chief. Before a PM is running, use assistant → chief and the existing launch tool. |
| `typedTools` gates requirements on tracker/task authority and freezes project enums at launch | Expose scoped discovery reads without a fabricated task; new code/review sessions must be built after project activation. |
| `task.create` documents that a lost create answer cannot currently be reread | Add durable operation markers and reconciliation before claiming lifecycle idempotency. |
| `checkPush`/`recordPush` do not bind a task's product requirement scope to `push.project` | Add that binding for scoped tasks before accepting bundle bytes and at record time. |
| `project` records accept direct `docs/NAME.md`, not `docs/prd/NAME.md` | Product PRDs use `docs/prd-VERSION.md`; do not reuse the static catalogue's path schema. |
| Gitea grants can provision identities/forks, but role Vikunja scopes cannot create projects | Reuse the installed tracker board and existing roles for v1. Do not invent a Vikunja provisioning capability. |
The configured factory is a selected declared project, not a hard-coded `stack` key. For new products clone its local code source at the displayed commit into `<dataRoot>/products/<business>/<project>/code`, preserve history, and write a local bootstrap commit changing only `.mosaic/project.json` to the new ID and explicitly displayed permitted project vars. Do not copy role overrides or broaden limits from the template. The artifact lives at `products/<project>/pomodoro.html` in the product clone; the factory checkout is never edited. Records default to `<dataRoot>/projects/<business>/<project>` and have no remote. Working storage remains `workingRepo(dataRoot,business,project)`.
Reuse the factory's validated upstream URL in the new mapping. Existing actor forks may be reused only after the existing identity/grant checks; a new product key does not inherit `publish` rights. Local `push_working` and `fetch_review` need no new remote grant. If remote publication is later requested, the chief uses the existing Gitea `credential.mint` path for the product scope and a separate `git.push.remote` approval for the exact commit. No remote provisioning is a hidden dependency of the first offline delivery.
## Producer/consumer contract
All shapes below are proposed strict, versioned interfaces. Reject extra keys at public boundaries. IDs use existing repository validators; hashes are SHA-256, Git commits use the existing full-OID validator. Broker-derived identity is never an agent argument. `request` is the original `human.input` event ID, not the chat message ID.
```text
ResourcePlan = {
version:1, business, request, project, name, chat_project:null|id,
mode:"autonomous"|"guided", organization_revision,
source:{project, commit, remote:null|httpsURL,
forks:[{instance,url,grant:null|id,state:"verified"|"not-granted"}]},
code_root, records_root,
tracker:{root:number, project:number},
staff:{pm, technical_reviewer, coder, reviewer, chief},
intent:{text, sha256, source_message, generated:boolean},
project_vars:{...validated project-layer vars, no tracker.project},
existing:boolean
}
PlanBinding = {plan:ResourcePlan, digest, decision, choice}
RequestState = {
version:1, request, business, revision:number, source_message,
thread:null|id, session:null|id, owner, pm,
phase:"selection"|"bootstrap"|"discovery"|"prd-review"|"planning"|
"implementation"|"review"|"delivery"|"delivered"|"cancelled",
project:null|key, chat_project:null|id, mode:null|"autonomous"|"guided",
mode_revision:number, plan_digest:null|hash, spec_commit:null|oid,
snapshot:null|id, tasks:[task_ref], delivery:null|id,
question:null|{id,text,options:[{key,text}],answer:null|event_id},
candidate:null|PrdCandidate, selection:null|{decision,bindings:[PlanBinding]},
route:null|"existing",
wait:null|{kind:"decision"|"setup"|"launch"|"effect",
code, subject, resume_phase, observed_revision}
}
PrdCandidate = {
version:1, request, project, prd_version:"1.0", records_commit,
prd_path:"docs/prd-1.0.md", prd_blob,
requirements:[{id,title,path,blob}], spec_commit, mode_revision
}
ApprovedSnapshot = {
version:1, id, business, project, prd_version, records_commit,
prd_path, prd_blob, spec_commit, requirements:[{id,title,text,path,blob}],
authors:[{definition,instance,commit}],
approval:{decision, resolution_seq, by, via}, mode_revision,
origin:"product"
}
BuiltinSnapshot = {
origin:"builtin", id, business, project, prd, version,
requirements:[{id,title}], catalog_sha256
}
CatalogSnapshot = ApprovedSnapshot | BuiltinSnapshot
TaskScope = {project_key, requirement_snapshot, requirement}
EffectReceipt = {
version:1, business, request, op, kind, digest,
stage:"prepared"|"committed"|"uncertain"|"refused",
result:null|object, code:null|string
}
Delivery = {
version:1, id, business, request, project, snapshot,
task_ref, commit, review_request, verdict,
artifact:{path,blob,sha256,bytes,mime:"text/html"},
verification:{record_path,record_commit}, message
}
```
`request_events` stores `{seq,business,request,revision,kind,actor_role,actor_run,at,body}` with unique `(business,request,revision)` and append-only triggers. `body` is a validated transition payload; `readRequest` reduces it into `RequestState`. Creation is unique per source event. `request_effects` stores the `EffectReceipt` fields plus sequence/time, unique `(business,request,op,stage)`; an operation's digest/kind cannot change. `request_snapshots` stores immutable `ApprovedSnapshot` JSON keyed by ID, and `request_snapshot_events` appends activation/revocation with project, revision, snapshot and cause. `request_project_events` stores `{seq,business,project,revision,kind,at,body}` with unique `(business,project,revision)` and append-only triggers. It holds shared project mode changes and attachment evidence. Mode writes compare the displayed project mode revision as well as the request revision in one transaction; another attached request cannot race a stale mode write through. These are operational records, not system configuration. All indexes/projections are rebuildable. Never repurpose mutable `status` files as authority.
Only fixed effect kinds are supported: `bootstrap`, `prd-submit`, `prd-activate`, `task-create`, `continuation`, `delivery`. Intermediate bootstrap steps have deterministic keys under their parent's op. No agent-facing “execute arbitrary effect” API.
## Task 1: Durable pre-task intake and selection boundary (stage 2)
**Files:** Create `packages/bus/src/request-lifecycle.mjs`, `packages/bus/src/request-store.mjs`, `packages/bus/src/migrations/3n-3o.sql`, `packages/bus/tests/request-intake.test.mjs`. Modify `packages/bus/schema.sql`, `packages/bus/src/store.mjs`, `packages/bus/src/broker.mjs`, `packages/bus/src/runtime.mjs`, `packages/business/src/vocabulary.mjs`.
**Interfaces:** `createRequestStore(store)` returns `readRequest(business,request)`, `appendRequest({business,request,expectedRevision,kind,body,actor})`, `prepareEffect(receipt)` and `finishEffect(receipt)`. These are host-only. `createRequestLifecycle({journal,services})` returns `open(identity,args)`, `get(identity,args)`, `resume(business,request)` and `recover()`. Broker verbs: `request.open({request})`, `request.get({request})`, `request.projects({request})`, and PM-only `request.propose({request,revision,op,source_project,project,name,tracker_project})`. Opening captures intake; proposing validates a concrete resource plan and creates the mode decision. The PM chooses a sensible source/name from declared resources; the user sees and confirms them before any bootstrap effect. PM may open a reachable user-origin request through existing `message.send` authority; owner may read its own request. Opening twice returns the same state.
- [ ] **Red:** Add `opens_without_task_or_requirement`, `duplicate_open_is_same_request`, `rejects_foreign_input`, `non_owner_cannot_select` and `reopen_store_reduces_same_state`. Assert no `task.created` and null `task_ref`/`requirement_ref` on intake decisions.
- [ ] **Red verification:** Run `node --test packages/bus/tests/request-intake.test.mjs`; new exports/verbs must fail before implementation.
- [ ] **Green:** Add append-only tables, offline migration and schema digest/version updates using existing migration checks. Keep earlier migrations immutable; if another integrated change takes `3o`, rebase this single new migration rather than editing its predecessor.
- [ ] **Green:** Add `project.bootstrap` to both closed `ACTIONS` lists (`business/src/vocabulary.mjs` and `bus/src/broker.mjs`) only; update their equality/closed-vocabulary tests, never either `ROUTED` list. `request.propose` builds the three exact keys `autonomous`, `guided`, `existing` in a user-routed nonblocking decision, with `approvalChoice: "autonomous"`. This decision is an enumerated selection: guided/existing are valid selections, not denials inferred from `approvalChoice`. Its lifecycle executor uses the immutable choice-to-plan bindings, never generic action consumption. Before opening it, prepare and validate the displayed resource proposals; if prerequisites are missing, open one setup wait instead. Cancellation uses existing withdrawal, creates no resources, and retains the source request.
- [ ] **Green:** For `existing`, return only accessible software project mappings; exclude library projects, unknown tracker associations and another business. A picker stages a candidate binding; a subsequent ordinary decision confirms that exact association. Existing matching requirements still require an explicit scope choice by PM; no text matching silently establishes authorization.
- [ ] **Verification:** Run `node --test packages/bus/tests/request-intake.test.mjs packages/bus/tests/schema.test.mjs packages/bus/tests/store.test.mjs packages/business/tests/hard-waits.test.mjs`. Expect zero failures; the hard-wait test intentionally gains only `project.bootstrap`.
- [ ] **Commit:** Commit this task's named implementation/tests only after green checks, when the execution assignment authorizes local commits.
## Task 2: Protected project bootstrap that becomes usable at runtime (stage 2)
**Files:** Create `packages/business/src/project-bootstrap.mjs`, `packages/bus/src/request-bootstrap.mjs`, `packages/business/tests/project-bootstrap.test.mjs`, `packages/bus/tests/request-bootstrap.test.mjs`. Modify `packages/business/src/organization.mjs`, `packages/project/src/index.mjs`, `packages/bus/src/runtime.mjs`, `packages/bus/src/broker.mjs`, `packages/cli/src/config.mjs`, `packages/cli/src/launcher.mjs`, `packages/bus/src/process.mjs`, `packages/seat/src/working.mjs`. Test existing `packages/business/tests/library-projects-protected.test.mjs`, `packages/project/tests/init-failure.test.mjs`, `packages/cli/tests/launcher.test.mjs`.
**Interfaces:** `prepareBootstrap({business,request,sourceProject,project,name,mode,dataRoot,organizationRevision,tracker,staff,intent}) -> Promise<ResourcePlan>` in `request-bootstrap.mjs`; `project-bootstrap.mjs` validates the exact mapping/path addition for the organization writer. `executeBootstrap({binding,journal,services}) -> Promise<{project,chat_project,spec_commit,organization_revision}>`. `organizationWriter().addProject(id,{revision,project,entry,receipt},actor)` is a new narrow host method; `entry` is only `{root,repo,remote?}`. `activateManagedProject({business,project,receipt,organization_revision}) -> Promise<void>` is a runtime-only operation. Host IPC `request.admission({business,project}) -> {ready,revision,code}` exposes readiness to the launcher through `process.mjs`; scope it to the host business and fail closed if unavailable. `initializeWorking({dataRoot,business,project,root,env?}) -> Promise<{repo,commit}>` is a new host-only export in `seat/src/working.mjs`, using its existing repository lock and private safe `seed` helper, with no work ref or push authority. Bootstrap calls it after the code seed commit and journals the returned root commit. Existing `initProject`, `writeHumanFile` and `project.create` remain compatible.
- [ ] **Red:** Test `bootstrap_connects_all_resources`: inspect the actual organization file, code `.mosaic/project.json`, records HEAD/spec author, chat association, tracker tree/board, working-repo seed and next launch's project list. Test stale organization/plan, changed source commit, symlink ancestor, path overlap, preexisting unrelated data and partial initialization. Assert every ordinary organization writer still rejects project changes.
- [ ] **Red verification:** Run `node --test packages/business/tests/project-bootstrap.test.mjs packages/bus/tests/request-bootstrap.test.mjs`.
- [ ] **Green:** Under the existing organization lock/receipt discipline, factor a private exact-add writer used only by `addProject`; reject replacement/removal, library entries, unrelated key changes and a mismatched plan receipt. Compare both input and serialized document. A plan with a stale revision requires a new preview/confirmation, never silent rebasing.
- [ ] **Green:** Journal path reservations, clone/seed, mapping addition, records init, exact-intent save, chat association and runtime activation separately. `initProject` currently requires a declared project: prepare the code seed, publish the reserved mapping, then initialize records under that mapping. Mark it pending in broker admission until every step passes. For interrupted local Git effects compare deterministic paths, stored before/after commits and exact bytes; retain ambiguous contents and return `bootstrap-uncertain`. Do not remove unrelated paths or rewind commits.
- [ ] **Green:** Save original displayed intent through `writeHumanFile` as the authenticated owner recorded by the decision; generated wording requires its own exact preview/confirmation. Record confirmation event and resulting spec commit. Keep `writeRecord` unable to write `spec/`.
- [ ] **Green:** Preserve the single tracker root: the product file omits `tracker.project`; persist its selected board in the immutable plan and admission projection. `trackerFor` must ignore reserved managed additions until committed and verify product IDs after completion, including after a fresh boot. Never create a second tracker adapter per product or fall back to the root board on failure.
- [ ] **Green:** Extend runtime reload only for receipt-backed additive roots within `<dataRoot>/products`, already excluded from secret locations by the Credentials data-root fence. Revalidate the full business, unchanged credentials/bots/libraries/roles, prior mappings and completed plan. Admit the mapping to broker only after readiness; rebuild readiness from receipts on restart. Launcher already rereads business in `check`; additionally refuse a reserved/unready product from cloning until admitted. Existing arbitrary reload changes still answer `restart: true` or refuse. Existing sessions receive no new project tool enum; Task 6 schedules a fresh suitable coder/reviewer session.
- [ ] **Verification:** Run the new tests plus `node --test packages/business/tests/library-projects-protected.test.mjs packages/project/tests/init-failure.test.mjs packages/cli/tests/launcher.test.mjs`. Kill/reopen the fixture after mapping write and after spec commit; recovery must reach the same mapping/spec exactly once, without changing factory files.
- [ ] **Commit:** Commit this bounded bootstrap path after green checks. Do not execute bootstrap on any running instance.
## Task 3: Intake Console controls and guided answers (stage 2)
**Files:** Create `packages/webui/src/request-http.mjs`, `packages/webui/src/public/requests.js`, `packages/webui/tests/request-http.test.mjs`, `packages/webui/tests/request-browser.test.mjs`. Modify `packages/webui/src/serve.mjs`, `packages/webui/src/console-bus.mjs`, `packages/webui/src/bus.mjs`, `packages/webui/src/public/index.html`, `packages/webui/src/public/chat.js`, `packages/webui/tests/browser.test.mjs`, `packages/bus/src/request-lifecycle.mjs`, `packages/bus/src/broker.mjs`.
**Interfaces:** `requestHTTP(req,res,{path,requestRead,requestWrite,session,origin})` handles `GET /api/requests/:request`, `GET /api/requests/:request/projects`, and `POST /api/requests/:request/answer|attach|mode`. The reads call `request.get.console` and `request.projects.console`; the writes call owner-only broker verbs `request.answer.console({request,revision,question,op,text})`, `request.attach.console({request,revision,project,op})`, `request.mode.console({request,revision,mode_revision,mode,op})`. All return `RequestState`; authenticated owner reads also support `GET /api/requests/:request/draft` calling `request.draft.console({request})` and returning the candidate PRD text and full requirement records at its bound commit. That read is produced by Task 4 and is not a generic file-read endpoint. Attachment stages a confirmation decision, not a mapping write. `request.question({request,revision,op,text,options?})` is PM-only and records an outstanding question ID; text answers are not PRD approvals.
- [ ] **Red:** Test signed-in owner selection, authorized picker, guided free-text answer and stale revision. Assert denied origins, expired sessions, readers/testers/granted nonowners cannot mutate intake. No generic decision endpoint/schema changes are part of this task.
- [ ] **Red verification:** Run `node --test packages/webui/tests/request-http.test.mjs packages/webui/tests/request-browser.test.mjs`.
- [ ] **Green:** Mount the intake panel from the source chat request. Render server-selected plans and waiting reason as escaped data; use Stage 1's authoritative decision options and existing confirmation endpoints for decisions. New text/attach/mode writes enforce the same signed-in owner, same-origin, writable-Console and bounded-body rules. Extend `CONSOLE_VERBS` with only these new `.console` verbs, using `consoleCall` and its existing verified `(provider,iss,sub)` transport, never an owner CLI proxy selected by client input. Check `s.console` and the configured owner explicitly; the existing `#human` CLI guard rejects Console capabilities and must not be weakened. Agent intake reads keep their unsuffixed names. Mode change is an explicit user control with expected revision; it invalidates pending autonomous approval/dispatch admissions at the next action.
- [ ] **Green:** Guided answers become attributed `human.input` events linked to question and request, with duplicate op protection. They do not silently rewrite the original spec. Persist answers; enqueue PM continuation after commit. “Cancel” withdraws the selection or appends cancellation; an already-created project remains evidence, never auto-deleted.
- [ ] **Verification:** Run the two new suites and `node --test packages/webui/tests/browser.test.mjs`. Update its exact script-list expectation for `requests.js`. Test reload after lost answer response: read the stored answer/op first, no blind repost. Existing grant summaries and confirmation regressions remain green after Stage 1 integration.
- [ ] **Commit:** Commit intake-specific UI and tests; do not fold generic Console decision work into this commit.
## Task 4: Exact-version product PRD approval and activation (stage 3)
**Files:** Create `packages/project/src/prd.mjs`, `packages/bus/src/requirement-catalog.mjs`, `packages/project/tests/prd.test.mjs`, `packages/bus/tests/prd-activation.test.mjs`. Modify `packages/bus/src/request-lifecycle.mjs`, `packages/bus/src/broker.mjs`, `packages/bus/src/runtime.mjs`, `packages/project/src/index.mjs`, `packages/project/src/read.mjs`, `packages/webui/src/request-http.mjs`, `packages/webui/src/public/requests.js`.
**Interfaces:** `readPrdCandidate({business,project,dataRoot,request,prd_version,records_commit,prd_path,requirement_ids,mode_revision}) -> Promise<PrdCandidate>` reads bounded committed regular blobs using existing Git safety rules. `prd.submit({request,revision,op,prd_version,records_commit,prd_path,requirement_ids}) -> {candidate,decision}` is PM-only, requires `record.write`, and internally raises the existing `prd.approve` action. `createRequirementCatalog({journal,readSpec,builtinBinding})` returns `resolve({business,project,snapshot?}) -> Promise<CatalogSnapshot>` and `activate({request,decision}) -> Promise<ApprovedSnapshot>`; the latter is host-only. `Broker.requirementsFor(cap,{project_key,requirement_snapshot?,requirement?}) -> Promise<CatalogSnapshot>` is the shared, authenticated consumer used in Task 5.
- [ ] **Red:** Test autonomous PM → configured CTO approval, PM/self-role denial, guided user approval, changed draft, changed requirement blob, stale spec, changed mode, revoked snapshot, wrong business and forged `status: accepted`. Assert no task exists before activation.
- [ ] **Red verification:** Run `node --test packages/project/tests/prd.test.mjs packages/bus/tests/prd-activation.test.mjs`.
- [ ] **Green:** Bound this first route to 64 requirements, 32 KiB PRD text, 16 KiB per requirement text and 256 KiB aggregate candidate content; reject oversized candidates before decision creation. PM writes `docs/prd-1.0.md` and `requirements/REQ-POMO-1.json` etc. with existing `record.write`; validate strict requirement records, IDs/titles, unique paths and exact content at one reachable records commit. Candidate hashes are server-read, never trusted agent assertions. At submission and activation, compare each selected path at current HEAD with its candidate blob; unrelated record commits do not invalidate it, but an edited selected draft requires a new submission. Read requirement JSON via a private committed-blob helper; do not broaden the public spec editor's file access.
- [ ] **Green:** In autonomous mode raise `prd.approve` with `domain: "technical"`, current confirmed spec citation and target bound to the candidate; require a distinct authorized arbiter instance/definition. Missing or self-routing technical arbiter becomes a setup wait, not self-approval. Guided mode deliberately raises the same gated action without a routing spec citation so it goes to the user; its separate candidate binding still includes and revalidates the current spec. Display exact draft, requirements and version through the request panel before approval.
- [ ] **Green:** After resolution, activation rereads the immutable candidate, current intent, mode and role authority (not whether the approving run is still alive) and checks matching approving choice/resolution. Append the snapshot and activation receipt atomically with the continuation. A changed candidate requires a new decision. Repeated activation returns the same ID. A mode/spec change revokes outstanding admissions; prior task evidence remains immutable. Owner `request.mode.console` and existing spec-save notices trigger this invalidation. Support owner-only `prd.revoke.console({project,snapshot,revision,reason,op})` for explicit revocation through `POST /api/requests/:request/revoke`, with the same Console controls and whitelist integration as Task 3; pending work waits for replacement authority.
- [ ] **Verification:** Run both new suites and `node --test packages/project/tests/read.test.mjs packages/project/tests/human-file.test.mjs packages/business/tests/spec-review-contracts.test.mjs`. Fresh runtime must resolve the same approved snapshot solely from committed records and journal evidence. Preserve existing decision closure/notification behavior.
- [ ] **Commit:** Commit activation/catalogue code and tests after green checks. Do not claim the Stack PRD amendment itself has already been approved.
## Task 5: One scoped requirement validator and recoverable tracker creates (stage 3)
**Files:** Create `packages/tasks/src/create-recovery.mjs`, `packages/tasks/tests/scoped-requirements.test.mjs`, `packages/tasks/tests/create-recovery.test.mjs`, `packages/bus/tests/scoped-push.test.mjs`. Modify `packages/tasks/src/verbs.mjs`, `packages/tasks/src/adapter.mjs`, `packages/tasks/src/startup.mjs`, `packages/tasks/src/sync.mjs`, `packages/bus/src/broker.mjs`, `packages/cli/src/config.mjs`, `packages/cli/src/git-socket.mjs`, `packages/bus/src/process.mjs`, `packages/bus/src/runtime.mjs`.
**Interfaces:** Broker `requirements({project_key?,requirement_snapshot?})` and host method `requirementsFor` both use Task 4's catalog. Add optional `{project_key,requirement_snapshot,op}` to existing `task.create`; product callers require all three plus existing `request` and `requirement`. Existing numeric `project` remains the tracker board ID, distinct from `project_key`. `reconcileCreate({ctx,receipt}) -> Promise<{state:"committed",task_ref}|{state:"uncertain",code}>` reads through sync authority, never writes as sync. Adapter exposes host-only `reconcileCreates(business)` and `inspectProject(business,project)` for startup/recovery/admission. Add host-only `Broker.recordRecoveredCreate({receipt,task_ref,snapshot})`: verify the prepared identity/body/target and write the missing task event once, with original attribution. Never expose this method as a socket verb.
- [ ] **Red:** Test two products on one board with the same requirement ID but different snapshots; draft/unapproved/revoked/wrong-project/wrong-board/ambiguous requests; omission by product callers; and explicitly bound legacy Stack compatibility. Assert broker and task creation return/accept the same exact snapshot, including its version and content commit.
- [ ] **Red:** Test lost response after tracker create, restart before receipt, duplicate PM calls, failure during label/assignment follow-up, a closed matching task, edited marker, duplicate marker and complete scan failure. Assert at most one create POST after an uncertain result; a negative/partial scan alone never authorizes another POST.
- [ ] **Red verification:** Run `node --test packages/tasks/tests/scoped-requirements.test.mjs packages/tasks/tests/create-recovery.test.mjs packages/bus/tests/scoped-push.test.mjs`.
- [ ] **Green:** Remove the private product ID set from `verbs`; call `Broker.requirementsFor` both when planning and immediately before create admission. Resolve tracker board from the request's confirmed binding, recheck installed tree membership and role/session authority, and append a prepared op before network I/O. Record `TaskScope`, source PRD version/commit, spec commit and op in `task.created` evidence. Task filtering/trail must distinguish product keys when IDs collide.
- [ ] **Green:** Bind the built-in catalogue only to a startup-validated Stack project, passed explicitly by `bootConfig` as `{business,project,tracker_project}`. Confirm its code project identity and repository-owned Stack PRD/catalogue relationship; do not infer it from the first project, any `REQ-*` prefix or the board number. Unscoped legacy calls preserve the existing `{prd,version,requirements}` response and may use that binding only when their source request has no intake record or conflicting product binding. Any intake request, including selection/discovery, requires an approved product snapshot before task creation. The internal builtin wrapper records the baked catalogue version/hash and never fabricates a product approval decision. New product calls never fall back. Keep `packages/tasks/requirements.json` and its existing approved-PRD consistency test unchanged in this task.
- [ ] **Green:** Use a stable visible description suffix `Mosaic operation: <op>; request: <request>; scope: <project_key>; snapshot: <snapshot>` added by the adapter. Journal the complete create-body digest and target board. On uncertainty, scan all pages including completed tasks with the sync reader; adopt exactly one matching marker only after validating board, creator bot and original create fields. A changed/ambiguous/missing marker stays `task-create-uncertain` with a reconciliation wait. Resume labels/relations through existing read-before-write checks. Record recovered create attribution from the original prepared identity, never from a fabricated live capability.
- [ ] **Green:** Add product binding to git admission: extend `checkPush(cap,task_ref,project)` and pass `project` from the git socket through `process.mjs`/runtime before reading bundle bytes; repeat in `recordPush`. A scoped task can push/review/export only from its bound project. Preserve legacy recorded task handling without inventing product scope for old evidence.
- [ ] **Verification:** Run new tests plus `node --test packages/tasks/tests/requirements.test.mjs packages/tasks/tests/verbs.test.mjs packages/tasks/tests/tree.test.mjs packages/tasks/tests/adapter.test.mjs packages/cli/tests/git-socket.test.mjs`. Exercise an actual file-backed Store close/reopen and persistent fake tracker; an in-memory reducer replay is not a restart test.
- [ ] **Commit:** Commit scoped validation and create recovery only after all listed checks pass.
## Task 6: Typed role continuations through implementation and review (stage 3)
**Files:** Create `packages/bus/tests/request-continuation.test.mjs`, `packages/harness/tests/request-tools.test.mjs`, `packages/cli/tests/request-launch.test.mjs`. Modify `packages/bus/src/request-lifecycle.mjs`, `packages/bus/src/broker.mjs`, `packages/bus/src/runtime.mjs`, `packages/cli/src/launcher.mjs`, `packages/bus/src/process.mjs`, `packages/harness/src/tools.mjs`, `packages/harness/src/runner.mjs`, `packages/seat/src/sandbox.mjs`, and duty contracts `roles/pm.md`, `roles/chief-of-staff.md`, `roles/cto.md`, `roles/coder.md`, `roles/reviewer.md`, `roles/assistant.md` in the later implementation only.
**Interfaces:** Extend `staff.request`/`request_staff` to exactly one of `{task_ref,...}` or `{request,...}`, keeping `{roles,note?,decision?}` and PM-only `message.send` checks. Add typed tools `intake_open`, `intake_status`, `intake_projects`, `intake_propose`, `intake_question`, `submit_prd`, `advance_request` mapping to the already specified verbs. Add `request.advance({request,revision,op}) -> RequestState` as PM-only: it verifies evidence for the next fixed transition, never accepts a claimed success state. Lifecycle `resume` records one durable continuation intent per `(request,revision,target,step)`; runtime `recover` sweeps unfinished intents on startup and after relevant events, plus a bounded 30-second runtime reconciliation pass for missed notifications. It processes only these fixed request stages, deduplicates by durable continuation key and does not add another scheduler service.
- [ ] **Red:** Test pre-task staff request, correct chief launch, wrong-role refusal, actual PM/CTO/coder/reviewer tool manifests, absent tracker during discovery, a running role with an old project enum, revoked launch, full limits and a dead role after message delivery. Existing assistant message restrictions must still reject direct PM/reviewer coordination.
- [ ] **Red verification:** Run `node --test packages/bus/tests/request-continuation.test.mjs packages/harness/tests/request-tools.test.mjs packages/cli/tests/request-launch.test.mjs`.
- [ ] **Green:** Preserve assistant → chief → PM intake and chief → assistant delivery. A PM uses discovery records and scoped catalog tools without creating a task; after activation it creates/assigns the task using Task 5 and requests coder/reviewer staff. Chief launches existing configured roles under the normal `launch` tool. Do not hire a PM/CTO/reviewer through the empty chief `hireable` list or add PM `role.launch` authority.
- [ ] **Green:** Continuations name source request/revision, current phase, evidence IDs and exact next typed operation. Decision closure, approved snapshot, task/create/review evidence, session end, successful launch and cleared prerequisites trigger reconciliation. Append continuation intent and outgoing message atomically; link acknowledgement to a validated transition/effect, not `message.read`. On a dead run with no progress, deliver the same continuation key to its successor and recheck existing effect receipts. Persist attempt/refusal state; avoid message floods and busy retry loops.
- [ ] **Green:** If the chief or worker is unavailable, store the exact existing launch refusal and surface it in the intake panel. A new relevant prerequisite revision/session event retries through the same chief path. No synthetic unlimited capability, direct privileged worker spawn or user command relay. A role that lacks the new product in its session manifest waits for its current turn to drain; chief uses its existing permitted end/relaunch path for that role, never interrupting unrelated active work. The next launch uses admitted mappings and real `ensureCheckouts`. Use Task 2's host-only `request.admission` read for readiness.
- [ ] **Green:** Update typed schemas with product snapshot fields and intake alternatives; expose reads for eligible PM/CTO before task existence and writes only to existing authorized roles. Ensure runner/MCP dispatch and sandbox module mounts include the new imported modules without mounting credentials/configuration. Update role duty prose to describe discovery, exact approval, resume and artifact completion; no `.json` authority grants are needed on this first route.
- [ ] **Verification:** Run new tests plus `node --test packages/harness/tests/tools.test.mjs packages/harness/tests/mcp-server.test.mjs packages/harness/tests/pi-session.test.mjs packages/business/tests/workflow-roles.test.mjs packages/business/tests/chief-of-staff.test.mjs`. Inspect manifests for both configured supported harnesses; no generic broker escape tool.
- [ ] **Commit:** Commit continuation/tool/contract updates after green checks and the normal execution review gate. This planning task makes none of those edits.
## Task 7: Export a reviewed artifact and deliver its evidence (stage 4)
**Files:** Create `packages/seat/src/artifact.mjs`, `packages/seat/tests/artifact.test.mjs`, `packages/bus/tests/request-delivery.test.mjs`, `packages/webui/tests/artifact-http.test.mjs`. Modify `packages/bus/src/request-lifecycle.mjs`, `packages/bus/src/runtime.mjs`, `packages/bus/src/broker.mjs`, `packages/webui/src/request-http.mjs`, `packages/webui/src/public/requests.js`, `packages/webui/src/bus.mjs`, `packages/harness/src/tools.mjs`.
**Interfaces:** `readReviewedArtifact({repo,commit,path,maxBytes}) -> Promise<{bytes,blob,sha256,mime}>` accepts a trusted working-repo path and a regular committed blob, max 1 MiB for this route. PM typed `deliver_request` → `request.deliver({request,revision,op,task_ref,verdict,path,verification_record}) -> Delivery`; `verification_record` is `{path,commit}` in the records repo. Host resolves all other evidence. `GET /api/requests/:request/artifact/:delivery` returns a protected attachment from broker-authorized delivery evidence. Broker read `request.artifact.console({request,delivery}) -> {mime,filename,base64,sha256}` is owner-only and bounded to 1 MiB decoded. Add it to `CONSOLE_VERBS`; set `consoleCall`'s output cap to 2 MiB for this verb only (its current 1 MiB cap cannot hold the base64 payload), below the existing bus Client 4 MiB response cap; the HTTP layer decodes bytes and enforces request ownership again. It is not a harness tool.
- [ ] **Red:** Test approved artifact bytes, stale verdict after newer push, changes verdict, wrong-project task, path traversal/symlink/submodule/oversize, forged verification record and duplicate export/notification. Assert exact blob/hash, task scope, review request and commit equality.
- [ ] **Red verification:** Run `node --test packages/seat/tests/artifact.test.mjs packages/bus/tests/request-delivery.test.mjs packages/webui/tests/artifact-http.test.mjs`.
- [ ] **Green:** Coder builds/tests the actual file, commits locally in its sandbox clone and calls `push_working`. A distinct reviewer fetches the exact work commit with `fetch_review`, runs the acceptance checks, writes an attributed verification record through `record.write` and emits `review_verdict`. PM closes with that verdict through existing `task.close`; `request.deliver` requires both closed-task evidence and latest approving review, and confirms the verification record cites the same commit/results and was committed through `record.write` by the reviewing role, proven by the broker action receipt rather than text claiming a reviewer identity.
- [ ] **Green:** Export only the committed working-repo blob through bounded safe Git operations, never a workspace path. Append delivery evidence and chief notification intent atomically. Link it to the original thread/session; chief and assistant relay automatically through existing messaging rules. Delivery read remains available even if the assistant is temporarily unavailable, but the outstanding notification stays visible and recoverable. Exactly one delivery ID/message intent per op.
- [ ] **Green:** Serve HTML as a download (`Content-Disposition: attachment`, `nosniff`, private/no-store); never execute product HTML inside the privileged Console origin or relax its CSP. The downloaded file runs locally offline. The evidence panel links PRD/snapshot, task, code commit, review and verification records. It labels reviewed delivery separately from landing/deployment.
- [ ] **Verification:** Run the three suites; open the downloaded file in the isolated acceptance browser with networking disabled, then confirm its bytes match the reviewed Git blob. Existing `task.close` refuses stale/changes verdicts as before.
- [ ] **Commit:** Commit artifact delivery only after green checks.
## Task 8: Three-path integration, crash recovery and honest acceptance (stage 4)
**Files:** Create `packages/cli/tests/request-lifecycle.test.mjs`, `packages/cli/tests/fixtures/request-agent.mjs`, `packages/cli/tests/fixtures/pomodoro-request.json`, `packages/webui/tests/request-lifecycle-browser.test.mjs`, `packages/cli/live/request-lifecycle.mjs`. Reuse `packages/tasks/src/fake.mjs`, the file-backed broker runtime, real project Git helpers, launcher/session fixtures and `packages/webui/tests/browser.mjs`. Do not reuse `tasks/tests/world.mjs`'s permissive PM launch grant as shipped authority.
**Interfaces:** Fixture request file is `{source_event,source_message,text,source_sha256,provenance}`. The integration fixture exposes `start()`, `kill(point)`, `restart()` and `evidence()` over the same on-disk instance and persistent fake service. Scripted role adapters invoke the real typed tools and create real commits; they do not insert successful lifecycle records directly. Live runner takes an explicitly named isolated instance and evidence output directory, validates resources/owner authorization first, and never defaults to .53/.54 or production.
- [ ] **Red:** `autonomous_original_request_delivers_offline_html`: submit the original request, select/confirm autonomous once, then let role adapters run through PRD approval, real tracker task, code/test/commit, independent role review and export. Assert zero user message relays, shell commands and task creation before snapshot activation.
- [ ] **Red:** `guided_answers_and_exact_approval`: answer a short question via Console, reject a draft, revise, approve exact content and deliver. `attach_existing_and_revise_missing_requirement`: attach to an authorized existing product; test both matching approved snapshot and discovery/revision when none fits; create no extra project. Also test cancellation and mode change during approval.
- [ ] **Red verification:** Run `node --test packages/cli/tests/request-lifecycle.test.mjs packages/webui/tests/request-lifecycle-browser.test.mjs`.
- [ ] **Green:** Build the fixture with actual shipped role definitions and valid isolated organization/tracker credentials; fake only external services and model responses. Use real task field validation, local repositories, git socket/bundles, typed manifests, launcher admission, separate role/run identities and signed-in HTTP/browser actions. The coder fixture writes working HTML, not a README describing how the user should build it.
- [ ] **Green:** Crash a separate process after selection resolution, mapping write, spec commit, PRD resolution, tracker POST, working push, review verdict and export-before-notification. Restart the actual broker/runtime against the same Store/repositories/fake-service data. Assert identical IDs/commits, one project/task/artifact, unchanged historical rows and automatic continuation. Include uncertainty where the remote outcome cannot be proven: a visible wait is correct; a duplicate write or success claim is not.
- [ ] **Green:** Check proposed product defaults only after their product PRD approval: 25-minute work, 5-minute break, start/pause/reset, phase-end cue, no network/external assets. Browser tests control time to verify transitions, pause/resume/reset and cue after a user gesture. Assert downloaded HTML is self-contained and usable offline. Those defaults are agent proposals, not invented prior owner requirements.
- [ ] **Verification:** Run `node --test packages/bus/tests/*.test.mjs packages/tasks/tests/*.test.mjs packages/project/tests/*.test.mjs packages/business/tests/*.test.mjs packages/cli/tests/*.test.mjs packages/harness/tests/*.test.mjs packages/seat/tests/*.test.mjs packages/webui/tests/*.test.mjs` in the repository's supported Node/browser environment. Record failures, skips and environment limitations explicitly; isolate a flaky/preexisting failure before claiming an unaffected suite green. Document/reference/whitespace checks also run before each final integration commit.
- [ ] **Verification:** Run the separate live runner only in a later explicitly authorized isolated configured instance with actual model credentials/role routes. It must use the same production code and real independent role sessions. Retain request, activation, task, commit, test, review and artifact receipts. Fixture success demonstrates integration/recovery, not live model quality or deployment readiness. No production setup or external resource creation is authorized by this planning assignment.
- [ ] **Commit:** Commit the acceptance harness and evidence references after applicable checks. Report fixture and live results separately; if live prerequisites are absent, report the exact remaining setup gate instead of marking stage 4 fully accepted.
## Protected setup, policy deltas and handoff
No `.json` role expansion is necessary for the selected first route: PM already writes records/tasks; CTO resolves technical decisions; coder pushes local working refs; reviewer issues verdicts; chief launches; assistant relays. The unavoidable implementation boundary is a new user-gated `project.bootstrap` action plus its narrow executor and protected project-map writer. The PRD addendum explicitly states that routing and scope. Other new verbs are bounded implementations of existing record/message/task/PRD authority or authenticated owner controls, not new permission grants. Reviewed duty-contract edits and corresponding schema/tool tests are still necessary.
Missing model account/route, PM/CTO/reviewer role, tracker sync credential or installed board is a real foundation setup refusal. The supported route reuses those declared resources; it must not create new service accounts by pretending a project-mode answer granted `account.create` or `credential.mint`. Show `{service,instance,missing_scope_or_resource,decision,can_execute,resume_condition}` without secrets. For existing Gitea grant needs, use the chief's established `credential.mint` request and concrete Console confirmation; existing bounded grant scope may apply only where already configured. For missing Vikunja token issuance, existing `credentials.issue` accepts a user-provided token through the protected credentials control; it is **not** an automatic project/token provisioner. If no installed board exists, state `tracker-install` and that the current executor cannot provision it. That is a protected foundation setup blocker, not delivery completion. Do not ask the user to edit files, forward role messages or run decision commands as the normal lifecycle.
Implement in Task order except that Task 3 may follow Task 4 while waiting for Stage 1 integration. Tasks 4/5 share the catalog contract; integrate together before enabling product task creation. Keep the feature unavailable until bootstrap admission, scoped validation and typed continuations all exist; no prompt-only partial release. The shortest end-to-end milestone is one product, one scoped task, one code commit/review and one downloaded HTML artifact. Multiple products share the same machinery; reusable mandates, automatic external project provisioning and broader artifact types are deferred.
The checked-in design names the original pomodoro request but does not preserve its verbatim text/source event. The lead must supply a read-only export of that source for the final acceptance fixture; do not relabel a reconstructed prompt as the original. Fixture development can use an explicitly labeled representative request meanwhile. No other builder choice requires a fresh owner decision: source project and board are selected from actual configured resources in the displayed initial plan. Exact PRD amendment adoption remains a separate versioned approval record; this document is not that record.
Lead provenance update, 2026-11-03: the owner's original incident quotation is
preserved at `/mnt/storage/scratch/tmp/mosaic-797-original-request-provenance.json`.
It contains the exact pomodoro wording, the original input/message IDs reported
by the owner, a SHA-256 of the wording and the source conversation item. Use it
for the original-wording acceptance fixture and preserve its provenance fields.
It is an owner-supplied quotation, not an exported runtime database row or live
acceptance evidence. Fixture and later live-run reports must keep that distinction.
@@ -0,0 +1,73 @@
# User terminology and internal identifier migration
Tracking: Vikunja #798, [Gitea #1840](https://git.mosaicstack.dev/mosaicstack/stack/issues/1840).
Priority: after the overlapping #797 Console/intake work.
Status: inventory complete; implementation pending.
## Owner direction
Jason requested “user” rather than “human” in the system on 2026-11-03,
then explicitly included internal identifiers to prevent confusion. This covers
user-facing copy and agent instructions as well as names in live source,
configuration, transports and new stored records. It is a naming change,
with existing authority and historical evidence preserved.
## Verified inventory findings
Luna produced a read-only inventory in
`/mnt/storage/scratch/tmp/mosaic-798-terminology-inventory-report.md`.
The lead checked these consequential boundaries against the current source:
- `packages/bus/src/principal.mjs` accepts separate `human` and `user`
transport envelopes. The first returns the owner principal; the second
returns a granted-user principal. Renaming both to `user` would lose a
necessary distinction.
- `packages/business/src/business.mjs` requires the business's `human`
config key. Optional role messaging lists use `human` as the owner's
recipient identifier. This is present in roles with explicit messaging
restrictions, rather than every role JSON file.
- `packages/bus/schema.sql` admits `human.input`; its task evidence trigger
requires that original input event. Stored decisions and messages also
carry owner recipient/routing values.
- The #797 change edits the broker, Console decision HTTP adapter and browser
script. Re-inventory those call sites after integration before renaming.
The report's matching-line counts are sizing hints, not the number of required
edits or a completeness test. Use live, tracked source; exclude `v1/`, old work
packets, immutable run evidence, append-only logs and historical quotations.
## Implementation sequence
1. After #797 integrates, settle a consistent internal vocabulary. The proposed
distinction is `ownerUser` versus `grantedUser` where plain `user` would
collide. User-facing copy uses “user” and clarifies ownership only when useful.
2. Migrate current prose and agent instructions, then internal symbols and module
names with their callers. Luna can perform narrowly specified mechanical work.
Current examples and tests follow the new canonical names.
3. Have a stronger GPT agent own configuration, transport proof and persisted
vocabulary transitions. Specify the version/legacy-reader contract first,
then implement it with an independent authority/compatibility review.
This stage changes names, not permissions; it must not bypass a refusal.
## Compatibility acceptance
- Existing configurations and historical store fixtures remain readable through
an explicit validated compatibility path. New interfaces use the canonical
names; legacy spellings are confined to intentional compatibility handling.
- Conflicting or ambiguous old/new config fields and transport envelopes refuse.
A granted-user credential never becomes an owner credential through an alias.
- Owner and granted-user nonce, peer/process/parent proof, business binding,
Console and tester checks retain their current separation and strength.
- Readers understand old events, message routes, decision routes and error codes.
New writers and their readers agree on the new vocabulary. Old evidence is
never rewritten, and old request events still join to their tasks/decisions.
- Extend the existing business/role, bus principal/user-principal/messaging/
decisions and Console decision suites. Run affected package suites after
changes; an inventory alone is not implementation or compatibility evidence.
## Inventory outcome and workflow
No product files or instances were changed by Luna. The queue helper refused its
linked checkout; the inventory stayed in that checkout and made no attempt to
move queue state. The lead records its session outcome centrally here and in the
append-only logs. #798 remains open through implementation and verification.
@@ -0,0 +1,58 @@
# MVP 3 chat-channel recovery
Tracking: Vikunja #815, [Gitea #1843](https://git.mosaicstack.dev/mosaicstack/stack/issues/1843).
Source baseline: deployed `51525a31d`, schema 3s. Owner feedback is the
2026-11-04 `/chat` read error and refused Pomodoro Timer project creation.
## Diagnosis
The live business has no `plugins.channel` setting. The typed tester `plugins`
read succeeds and returns only the existing transcript usage source and
Claude Code harness selections. The Console logs the matching `channel-disabled`
refusal. `createChannelCaller` refuses before project dispatch; the failed create
did not create a project. The Case 1 starter also omits the WebUI channel.
The browser then obscures that specific refusal and draws successful-empty
project/thread statements despite a failed read.
## Task 1: Restore the configured live channel
- Use only the existing typed, revision-checked `mosaic plugins` /
`plugins set --tester` path against the pinned .53 copy and config. This
instance permits tester plugin management; use its real tester attribution.
- Privately back up the business file with mode 0600, then read the revision
immediately before applying only `plugins.channel: ["cli", "webui"]`.
- Preserve every other JSON field and plugin kind. Verify typed readback,
the transaction receipt and unchanged remaining configuration.
- No owner identity/cookie, direct configuration edit fallback, role/grant,
credential, model route, schema, service restart, deployment, bridge or .54
change. A refusal stops the operation and is reported; do not bypass it.
- Read health/service state without a signed-in request. Do not create a
project/thread or run the owner acceptance workflow. Reconcile an uncertain
outcome by readback; never blindly repeat the write.
## Task 2: Prevent recurrence and explain failures correctly
- In `docs/guides/case1-business.mosaic-stack.json`, explicitly enable the
CLI and WebUI channels. Preserve its no-tracker starter behavior and all
other role/routing/limit semantics; keep global channel defaults unchanged.
- In the Console chat view, give `channel-disabled` a fixed, understandable
message. Keep the user's draft and distinguish definite refusal from
uncertain delivery. Do not invent a Settings control or require the user
to run a terminal command as the normal recovery flow.
- After a failed initial read, do not claim there are no projects/threads.
Show unavailable/error state; retained cached data must not be presented as
a successful fresh read. A genuine successful empty read keeps its normal
creation UI. Avoid offering an enabled creation action when the known
channel refusal prevents it; preserve the draft for recovery.
- Add failing then passing factory-template and real-browser regression
coverage for disabled reads/writes, draft retention, recovery and genuine
empty data. Preserve HTTP status and sent/outcome fields, authentication,
same-origin checks and actual channel enforcement.
- Use the existing test harness and pinned dependencies. Run affected suites,
obtain an independent GPT review, then integrate and push. Do not expand
this into plugin-settings UI, internal identifier migration or new policy.
The live configuration repair and source fix are independent and may run in
parallel. This is a continuation of the owner-authorized MVP 3 acceptance/fix
work. Actual signed-in project-to-artifact acceptance remains with the owner;
these operational checks do not replace it.
+44 -3
View File
@@ -237,11 +237,13 @@ an operator inspect content and delivery evidence after an uncertain result.
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `requirements` | Role sessions only, as `task.create` is (row 90, #1584). With `{project_key,requirement_snapshot?}`, returns the exact current approved product snapshot through the shared host `requirementsFor` catalog validator; stale/revoked/foreign snapshots refuse and product reads never fall back. With no args, preserves the legacy response. The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `project.list` | No args, or `{includeArchived:true}`; human, tester and user sessions. The business's projects, oldest first, with archived projects hidden unless `includeArchived` asks for them; each returned archived project carries `archived:true`. A user still lists granted projects only. |
| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new work (`project-archived` from thread create/open/send, new session clears and thread run admission, room open/send, and product-bound request/PRD/task admission). Repository/product keys resolve to the chat archive journal too: an archived chat binding fences repository admission for all chat projects naming that repository, and every archived binding must be unarchived to restore it. Queued opens and clear successors are rechecked at launch and claim. Existing results, task completion receipts, cancellation, release/end and history stay usable; metadata-only session creation cannot execute while archived. |
| `project.unarchive` | `{project}`; human only (#797 MVP3). Restores the project completely; a project that is not archived refuses `project-not-archived`. Returns `{project,archived:false}`. |
| `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. |
| `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. |
| `room.read` | `{project,after?,limit?}`; human only, complete owned room. Oldest first across all sessions of member threads. Returns entries with first-message fields plus `thread`, `session`, `instance`, `ids` and `to`. Only consecutive human rows with equal at/body/class and distinct instances merge; repeated instances split identical sends. `limit` counts merged entries (default 50, 1–200). Continue after the last entry's last `ids` element; an `after` outside the room refuses `message-not-found`. No tester access or schema change. |
@@ -759,7 +761,11 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds
computed at each read and never stored. `priority` is the current row's `fields.priority`, null
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
first `task.created` for the same Vikunja task id in the business, so a task moved to another
project keeps it; null for a task the stack didn't create.
project keeps it; null for a task the stack didn't create. Scoped rows also carry
`project_key` and `requirement_snapshot` from that immutable creation evidence;
these are optional filters on `tasks` and appear in the task's trail. No scope is
invented for older tasks. `checkPush(cap,task_ref,project)` checks that binding
before bundle admission; `recordPush` repeats it, as do review/export consumers.
A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
@@ -1076,3 +1082,38 @@ TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
### Existing product intake
The request host lists only this business owner's declared, admitted code projects.
Each picker entry carries `mapping_revision`, the current organization revision.
The host retains the displayed inventory until the owner picks; a host restart
requires a fresh picker read. `request.attach.console` accepts no client revision
for the mapping: it journals the server's selected revision. `prepareExisting`
refuses a changed mapping and builds the confirmation from its existing paths,
source commit, tracker binding, staff and project mode. It creates no resources.
After confirmation, attachment holds the organization lock and the processing
lease through validation and its atomic receipt. It reuses an active, validated
product snapshot when that snapshot's original request has exactly the same intent
bytes. This is a conservative proof of fit; different intent enters discovery
for PM assessment and PRD revision. Discovery cannot borrow the older snapshot
for task creation. No PRD decision or historical snapshot is rewritten.
The existing `bootstrap.completed` journal payload has optional host-validated
attachment fields; old events retain their original meaning and schema 3s needs
no migration.
Lifecycle continuations wake a live PM when a new, complete validated push/review
evidence set becomes available. Committed attempt receipts deduplicate unchanged
evidence across recovery and host restarts. A normal recovery call inspects at
most its requested batch limit. An event overlapping a recovery keeps a full
reinspection cycle pending, in bounded batches, so retained history cannot hide
an already-inspected request until a later timer tick.
Assistant-to-chief-to-PM intake relays must link `in_reply_to` to the message
received. The broker derives the original request header from that authorized
recipient chain and its owner/thread/session evidence, never from a message body.
For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
+109
View File
@@ -364,6 +364,27 @@ CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest
-- row, and the rows are the audit trail (who archived or restored it, and when). The projects
-- table itself stays as written; an archived project keeps every record and is never deleted.
CREATE TABLE project_archives (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
op TEXT NOT NULL CHECK (op IN ('archive','unarchive')),
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_archives_project ON project_archives (business, project, seq);
CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END;
CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives
WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op
BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
@@ -530,3 +551,91 @@ CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops
CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TABLE request_effects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version=1),
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
op TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('bootstrap','prd-submit','prd-activate','task-create','continuation','delivery')),
digest TEXT NOT NULL CHECK (length(digest)=64 AND digest NOT GLOB '*[^0-9a-f]*'),
stage TEXT NOT NULL CHECK (stage IN ('prepared','committed','uncertain','refused')),
result TEXT CHECK (result IS NULL OR (json_valid(result) AND json_type(result)='object')),
code TEXT,
CHECK (stage<>'prepared' OR (result IS NULL AND code IS NULL)),
UNIQUE (business,request,op,stage)
) STRICT;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TABLE request_snapshots (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'
AND json_extract(body,'$.version')=1 AND json_extract(body,'$.origin')='product'
AND json_extract(body,'$.id')=id AND json_extract(body,'$.business')=business AND json_extract(body,'$.project')=project)
) STRICT;
CREATE TABLE request_snapshot_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision>=1),
snapshot TEXT NOT NULL REFERENCES request_snapshots(id),
kind TEXT NOT NULL CHECK (kind IN ('activated','revoked')),
cause TEXT NOT NULL,
UNIQUE (business,project,revision)
) STRICT;
CREATE TABLE request_project_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision>=1),
kind TEXT NOT NULL CHECK (kind IN ('attached','mode.changed')),
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'),
UNIQUE (business,project,revision)
) STRICT;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_effects_no_update BEFORE UPDATE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_effects_no_delete BEFORE DELETE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_effects_no_replace BEFORE INSERT ON request_effects WHEN EXISTS (SELECT 1 FROM request_effects WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND op=NEW.op AND stage=NEW.stage)) BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_snapshots_no_update BEFORE UPDATE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshots_no_delete BEFORE DELETE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshots_no_replace BEFORE INSERT ON request_snapshots WHEN EXISTS (SELECT 1 FROM request_snapshots WHERE id=NEW.id) BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_update BEFORE UPDATE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_delete BEFORE DELETE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snapshot_events WHEN EXISTS (SELECT 1 FROM request_snapshot_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -41,7 +41,7 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
// Preserve a bounded reason when an explicitly scoped refusal has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
else end(new BusError('invalid-response'));
} catch {
+88
View File
@@ -0,0 +1,88 @@
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TABLE request_effects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version=1),
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
op TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('bootstrap','prd-submit','prd-activate','task-create','continuation','delivery')),
digest TEXT NOT NULL CHECK (length(digest)=64 AND digest NOT GLOB '*[^0-9a-f]*'),
stage TEXT NOT NULL CHECK (stage IN ('prepared','committed','uncertain','refused')),
result TEXT CHECK (result IS NULL OR (json_valid(result) AND json_type(result)='object')),
code TEXT,
CHECK (stage<>'prepared' OR (result IS NULL AND code IS NULL)),
UNIQUE (business,request,op,stage)
) STRICT;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TABLE request_snapshots (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'
AND json_extract(body,'$.version')=1 AND json_extract(body,'$.origin')='product'
AND json_extract(body,'$.id')=id AND json_extract(body,'$.business')=business AND json_extract(body,'$.project')=project)
) STRICT;
CREATE TABLE request_snapshot_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision>=1),
snapshot TEXT NOT NULL REFERENCES request_snapshots(id),
kind TEXT NOT NULL CHECK (kind IN ('activated','revoked')),
cause TEXT NOT NULL,
UNIQUE (business,project,revision)
) STRICT;
CREATE TABLE request_project_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision>=1),
kind TEXT NOT NULL CHECK (kind IN ('attached','mode.changed')),
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'),
UNIQUE (business,project,revision)
) STRICT;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_effects_no_update BEFORE UPDATE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_effects_no_delete BEFORE DELETE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_effects_no_replace BEFORE INSERT ON request_effects WHEN EXISTS (SELECT 1 FROM request_effects WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND op=NEW.op AND stage=NEW.stage)) BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END;
CREATE TRIGGER request_snapshots_no_update BEFORE UPDATE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshots_no_delete BEFORE DELETE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshots_no_replace BEFORE INSERT ON request_snapshots WHEN EXISTS (SELECT 1 FROM request_snapshots WHERE id=NEW.id) BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_update BEFORE UPDATE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_delete BEFORE DELETE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snapshot_events WHEN EXISTS (SELECT 1 FROM request_snapshot_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END;
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
+50
View File
@@ -0,0 +1,50 @@
-- #797 / #1839 Task 2: the request journal learns two more kinds of event, bootstrap.stale (a confirmed plan no
-- longer matched the world, nothing was bound) and bootstrap.completed (every resource of a confirmed new-project
-- plan passed). request_events keeps its rows, its seq values and its guards; only the kind list widens. The guards
-- that read it, request_events_origin, request_events_revision and request_effects_binding, are dropped first so
-- the rename rewrites nothing, then created again as schema.sql has them. The copy runs with the guards gone, in
-- seq order, inside the migration's one transaction.
DROP TRIGGER request_events_origin;
DROP TRIGGER request_events_revision;
DROP TRIGGER request_effects_binding;
DROP TRIGGER request_events_no_update;
DROP TRIGGER request_events_no_delete;
DROP TRIGGER request_events_no_replace;
DROP INDEX request_events_request;
ALTER TABLE request_events RENAME TO request_events_3o;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3o ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='request_events';
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3o';
DROP TABLE request_events_3o;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
+45
View File
@@ -0,0 +1,45 @@
-- Task 4: preserve all evidence and guards, widen only lifecycle event kinds.
DROP TRIGGER request_events_origin;
DROP TRIGGER request_events_revision;
DROP TRIGGER request_effects_binding;
DROP TRIGGER request_events_no_update;
DROP TRIGGER request_events_no_delete;
DROP TRIGGER request_events_no_replace;
DROP INDEX request_events_request;
ALTER TABLE request_events RENAME TO request_events_3p;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3p ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='request_events';
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3p';
DROP TABLE request_events_3p;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
+53
View File
@@ -0,0 +1,53 @@
-- #797 / #1839 Task 3: the request journal learns three more kinds of event for the Console intake controls:
-- question.asked (the project manager's one open question), question.answered (the owner's answer, linked to a
-- human.input event) and attach.selected (the owner's pick of an existing project, before the PM proposes it).
-- 3p-3q (lifecycle Task 4 core) shipped prd.submitted, prd.activated, prd.invalidated and mode.changed, and this step
-- keeps every one of them: the new list is that list plus these three, so a 3q journal replays into 3r unchanged.
-- request_events keeps its rows, its seq values and its guards; only the kind list widens. The guards that read it,
-- request_events_origin, request_events_revision and request_effects_binding, are dropped first so the rename
-- rewrites nothing, then created again as schema.sql has them. The copy runs with the guards gone, in seq order,
-- inside the migration's one transaction.
DROP TRIGGER request_events_origin;
DROP TRIGGER request_events_revision;
DROP TRIGGER request_effects_binding;
DROP TRIGGER request_events_no_update;
DROP TRIGGER request_events_no_delete;
DROP TRIGGER request_events_no_replace;
DROP INDEX request_events_request;
ALTER TABLE request_events RENAME TO request_events_3q;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3q ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='request_events';
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3q';
DROP TABLE request_events_3q;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
+46
View File
@@ -0,0 +1,46 @@
-- #797 / #1839 Task 6b: retain all fifteen shipped 3r event kinds and add the checked advanced event.
-- Preserve rows, seq, sqlite_sequence, the request index and every guard exactly as the prior rebuild.
DROP TRIGGER request_events_origin;
DROP TRIGGER request_events_revision;
DROP TRIGGER request_effects_binding;
DROP TRIGGER request_events_no_update;
DROP TRIGGER request_events_no_delete;
DROP TRIGGER request_events_no_replace;
DROP INDEX request_events_request;
ALTER TABLE request_events RENAME TO request_events_3r;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3r ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='request_events';
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3r';
DROP TABLE request_events_3r;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
+21
View File
@@ -0,0 +1,21 @@
-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest
-- row, and the rows are the audit trail (who archived or restored it, and when). The projects
-- table itself stays as written; an archived project keeps every record and is never deleted.
CREATE TABLE project_archives (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
op TEXT NOT NULL CHECK (op IN ('archive','unarchive')),
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_archives_project ON project_archives (business, project, seq);
CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END;
CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives
WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op
BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END;
+12 -2
View File
@@ -37,6 +37,7 @@ const LAUNCH_OPS = new Set([
'sessionClearUncertain',
'sessionClearRetire',
'sessionClearStart',
'request.admission', 'request.launches', 'request.launchResult', 'request.release', 'request.chief', 'request.manifest',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -45,7 +46,7 @@ function launchOp(m) {
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record);
// Row 93 (#1587): the git socket's ops.
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref, m.project);
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args);
// Row 137 (#1631): the git socket's land op, and the launcher's question whether to bind it.
@@ -79,6 +80,14 @@ function launchOp(m) {
if (m.op === 'sessionClearUncertain') return runtime.sessionClearUncertain(m.record);
if (m.op === 'sessionClearRetire') return runtime.sessionClearRetire(m.record);
if (m.op === 'sessionClearStart') return runtime.sessionClearStart(m.record);
// Lifecycle Task 2 (#797): may the launcher clone this project's code checkout. Scoped to the one
// business and project named; a malformed ask answers launch-op-refused, never ready.
if (m.op === 'request.launches') return runtime.requestLaunches(m.business);
if (m.op === 'request.launchResult') return runtime.requestLaunchResult(m.record);
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
if (m.op === 'modelStall') return runtime.modelStall(m);
@@ -144,7 +153,8 @@ if (!process.send) {
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
config.tasks = tasksAdapter({ trackers });
}
runtime = await startBroker(config);
// The adapter above and the host's new-project preparation each read the tracker settings; only the adapter is built here.
runtime = await startBroker({ ...config, ...(trackers ? { trackers } : {}) });
if (closing) {
await runtime.close();
return;
+449
View File
@@ -0,0 +1,449 @@
import { requestArchived } from './broker.mjs';
// Lifecycle Task 2 (#797 / Gitea #1839): the host executor behind a confirmed new-project selection.
//
// prepareBootstrap builds the immutable resource plan the owner sees. executeBootstrap consumes exactly that
// plan's binding and walks the steps below, each under its own journal receipt (`bootstrap:<digest>:<step>`),
// so a restart re-drives it from the receipts and the on-disk evidence without doing any step twice:
//
// reserve re-verify the plan against the world (#800), then reserve the product paths and a marker
// seed clone the source commit into a claimed temporary checkout, drop its remote, commit the one project-file change
// working confine the working path, then seed the working repository from that commit
// mapping publish the one organization mapping through the private exact-add writer
// records initialize the records repository under that mapping
// intent save the original displayed intent as the owner's edit of spec/north-star.md
// chat associate the chat project
// activate mark the product complete, then admit the project to the running broker
//
// Every inspection compares deterministic paths, the stored commits and exact bytes. What it cannot prove
// is kept as it is and reported as `bootstrap-uncertain`; nothing unrelated is removed and no commit is rewound.
// Nothing is ever deleted recursively, and every write goes through an exclusively created file and a rename,
// inside directories that are verified not to be links, so a link in a source checkout cannot redirect a write.
// Before every effect the organization revision and the chat association are checked again (#800), and the check
// and the whole effect, including asynchronous git children, run under the organization lock (`guarded`). A project is ready
// only with both a complete marker and a committed activate receipt, so the marker goes first and nothing is
// admitted until it is published.
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { digest, refuse, validatePlan } from './request-store.mjs';
import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { initializeWorking, inspectWorkingRepository, workingRepo } from '../../seat/src/working.mjs';
import { initProject, writeHumanFile } from '../../project/src/index.mjs';
import { SAFE, identity } from '../../project/src/git.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../business/src/project-bootstrap.mjs';
const INTENT_PATH = 'spec/north-star.md';
const LAYOUT_SUBJECT = 'Initialize project layout v1';
const exists = path => { try { return lstatSync(path); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
const within = (parent, path) => path === parent || path.startsWith(parent + sep);
const overlaps = (a, b) => within(a, b) || within(b, a);
const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x);
// One synchronous git read, so the stale check has no await in it. null when git refuses.
function gitSync(root, args) {
try {
return execFileSync('git', [...SAFE, '-C', root, ...args],
{ env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(root) }, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
} catch { return null; }
}
// The checked-out commit of a repository root.
const headOf = root => gitSync(root, ['rev-parse', '--verify', 'HEAD^{commit}']);
// The one metadata file the seed rewrites. `.mosaic` must be a real directory and `project.json` a regular file in it,
// never a link, so neither a read nor a write can leave the checkout (C1).
const PROJECT_FILE = '.mosaic/project.json';
function metadataProblem(root) {
const dir = exists(join(root, '.mosaic'));
if (!dir || dir.isSymbolicLink() || !dir.isDirectory()) return '.mosaic is not a directory';
const file = exists(join(root, PROJECT_FILE));
if (!file || file.isSymbolicLink() || !file.isFile()) return `${PROJECT_FILE} is not a regular file`;
return null;
}
// The same rule on the committed tree: a tree for `.mosaic` and a regular blob for the project file.
function committedMetadataProblem(root, commit) {
const dir = gitSync(root, ['ls-tree', commit, '--', '.mosaic']), file = gitSync(root, ['ls-tree', commit, '--', PROJECT_FILE]);
if (!dir?.startsWith('040000 tree ')) return '.mosaic is not a committed directory';
if (!/^100(644|755) blob /.test(file ?? '')) return `${PROJECT_FILE} is not a committed regular file`;
return null;
}
// The chat project id is a function of the plan, so a retry computes the same one.
export function chatProjectId(request, planDigest) {
const h = createHash('sha256').update(`chat-project:${request}:${planDigest}`).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
// prepareBootstrap({ business, request, sourceProject, project, name, mode, dataRoot, organizationRevision, tracker, staff, intent })
// Resolves the validated, immutable plan for one mode. `business` is the organization document; the plan names
// the source commit, the two managed roots and the original intent exactly as the owner will confirm them.
export async function prepareBootstrap({ business, request, sourceProject, project, name, mode, dataRoot, organizationRevision, tracker, staff, intent }) {
const source = business?.projects?.[sourceProject];
if (!plain(source) || source.kind === 'library' || typeof source.root !== 'string') refuse('request-project-refused');
const commit = headOf(source.root);
if (!commit || metadataProblem(source.root) || committedMetadataProblem(source.root, commit)) refuse('request-project-refused');
const factory = loadProject(source.root);
// The plan carries variables only, so a project file with role overrides cannot be reproduced from it.
if (Object.keys(factory.roles).length) refuse('request-project-refused');
const vars = { ...factory.vars };
delete vars['tracker.project'];
const { root, repo } = managedPaths({ dataRoot, business: business.id, project });
return validatePlan({ version: 1, business: business.id, request, project, name, chat_project: null, mode, organization_revision: organizationRevision,
source: { project: sourceProject, commit, remote: source.remote ?? null, forks: [] }, code_root: root, records_root: repo,
tracker: { root: tracker.root, project: tracker.project }, staff: { ...staff },
intent: { text: intent.text, sha256: digest(intent.text), source_message: intent.source_message, generated: false },
project_vars: JSON.parse(JSON.stringify(vars)), existing: false });
}
// Read an existing declared project. No managed-path allocation, clone or writer runs here.
export function prepareExistingPlan({ business, request, project, name, mode, dataRoot, organizationRevision, tracker, staff, intent, chat_project = null }) {
const source = business.projects?.[project];
if (!plain(source) || source.kind === 'library') refuse('request-project-refused');
const commit = headOf(source.root);
if (!commit || metadataProblem(source.root) || committedMetadataProblem(source.root, commit)) refuse('request-project-refused');
const config = loadProject(source.root), vars = { ...config.vars };
if (config.id !== project) refuse('request-project-refused');
delete vars['tracker.project'];
return validatePlan({ version: 1, business: business.id, request, project, name, chat_project, mode,
organization_revision: organizationRevision, source: { project, commit, remote: source.remote ?? null, forks: [] },
code_root: source.root, records_root: source.repo ?? join(dataRoot, 'projects', business.id, project),
tracker, staff, intent: { text: intent.text, sha256: digest(intent.text), source_message: intent.source_message, generated: false },
project_vars: vars, existing: true });
}
class Uncertain extends Error {}
class ForeignWorking extends Uncertain {}
// executeBootstrap({ binding, owner, journal, services }) -> { project, chat_project, spec_commit, organization_revision }
// services: dataRoot, organization (the writer), chat { state, associate }, activate (synchronous), barrier (tests).
// Throws RequestError('request-plan-stale') before any effect when the world no longer matches the plan; any other
// failure leaves the receipts as they are for the next run.
export async function executeBootstrap({ binding, owner, journal, services }) {
const plan = validatePlan(structuredClone(binding?.plan));
if (plan.existing || binding.digest !== digest(plan)) refuse('invalid-request-plan');
const { organization: writer, dataRoot } = services;
const { business, project, request } = plan;
const planDigest = binding.digest, base = name => `bootstrap:${planDigest}:${name}`;
const here = managedPaths({ dataRoot, business, project });
const workingPath = workingRepo(dataRoot, business, project);
const product = dirname(here.root), marker = markerFile({ dataRoot, business, project });
const chatId = chatProjectId(request, planDigest), human = () => writer.read(business).document.human;
const active = () => {
if (requestArchived(journal.readRequest(business, request), services, [project, plan.source.project, plan.chat_project, chatId])) refuse('project-archived');
};
const uncertain = () => { throw new Uncertain('bootstrap-uncertain'); };
const markerOwns = () => {
const m = readMarker(marker);
return m !== null && m.version === 1 && m.project === project && m.request === request && m.plan_digest === planDigest;
};
const entry = { root: here.root, repo: here.repo };
const receipt = { request, plan_digest: planDigest, project, data_root: dataRoot };
// A path is confined when it and every ancestor up to the data root are real directories, never links (C1).
const confined = path => {
for (let p = path; p !== dataRoot; p = dirname(p)) {
if (!within(dataRoot, p)) return false;
const st = exists(p);
if (!st || st.isSymbolicLink() || !st.isDirectory()) return false;
}
return true;
};
// Writes through an exclusively created sibling and a rename, so a link sitting at `file` is replaced and never
// followed. `stage` creates the sibling and returns the rename; a `temp` left by an earlier attempt is this plan's
// own only if it is a regular file with exactly these bytes, otherwise nothing is touched and the step is uncertain.
const stage = (file, temp, bytes) => {
if (!confined(dirname(file)) || !confined(dirname(temp))) uncertain();
const old = exists(temp);
if (old) { if (!old.isFile() || readFileSync(temp, 'utf8') !== bytes) uncertain(); unlinkSync(temp); }
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { writeSync(fd, bytes); } finally { closeSync(fd); }
return () => renameSync(temp, file);
};
const publish = (file, temp, bytes) => stage(file, temp, bytes)();
// #800 before every effect, not only the first: the organization is still at the revision this plan expects (the
// confirmed one before the mapping, this plan's own post-add one after it), the mapping is the plan's, and the chat
// association is not another's. `fresh` runs under the organization lock, so a bare `fresh()` proves the world
// matched at that instant and nothing more: the lock is released when it returns. What keeps a write from landing
// under a stale plan is `guarded(effect)`: the caller has finished every asynchronous read the effect depends on,
// and the check and the entire effect run with the lock held, through every awaited child. A mismatch keeps
// what is done and performs nothing further. The effect must not take the organization lock itself; mapping
// uses the writer's own exact-add transaction separately.
const checkFresh = ({ revision, document }) => {
active();
const stale = () => refuse('request-plan-stale');
if (document.id !== business || document.human !== owner) stale();
const added = writer.findAddition(business, { request, plan_digest: planDigest, project });
if (added) { if (revision !== added.after || canonicalJson(document.projects?.[project]) !== canonicalJson(entry)) stale(); }
else if (revision !== plan.organization_revision || Object.hasOwn(document.projects ?? {}, project)) stale();
if (services.chat.state({ business, project, name: plan.name, id: chatId }) === 'conflict') stale();
};
const fresh = () => writer.hold(business, checkFresh);
const guarded = effect => writer.holdAsync(business, now => { checkFresh(now); return effect(); });
const git = (cwd, args, extra = {}, file = false) => {
if (args.includes(`--git-dir=${workingPath}`)) {
if (!confined(workingPath)) uncertain();
inspectWorkingRepository(workingPath);
}
return runGit([...SAFE, ...(file ? ['-c', 'protocol.file.allow=always'] : []), ...(cwd ? ['-C', cwd] : []), ...args],
{ ...gitEnv(), ...(cwd ? { GIT_CEILING_DIRECTORIES: dirname(cwd) } : {}), ...extra });
};
const out = async (cwd, args, extra, file) => { const r = await git(cwd, args, extra, file); if (!r.ok) refuse('bootstrap-git-failed'); return r.out.trim(); };
const tryOut = async (cwd, args) => { const r = await git(cwd, args); return r.ok ? r.out.trim() : null; };
// One receipt per step. A committed receipt replays its result; anything else re-inspects the evidence.
async function step(name, run, { after = true } = {}) {
active();
const op = base(name), last = journal.effects(business, request).filter(r => r.op === op).at(-1);
if (last?.stage === 'committed') return last.result;
if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused');
// An early refusal, before anything is recorded. The effects below each check again through `guarded`.
if (name !== 'reserve') fresh();
const header = { version: 1, business, request, op, kind: 'bootstrap', digest: planDigest };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
let result;
try {
// Every effect after the reservation builds inside a product directory the marker proves is this plan's. A marker that
// is gone, unreadable or another plan's means ownership can no longer be shown, so nothing further is done and nothing
// is rewritten: the step is uncertain and the product stays unadmitted.
if (name !== 'reserve' && !markerOwns()) uncertain();
result = await run({ recovering: last !== undefined });
} catch (e) {
if (!(e instanceof Uncertain)) throw e;
journal.finishEffect({ ...header, stage: e instanceof ForeignWorking ? 'refused' : 'uncertain', result: null, code: 'bootstrap-uncertain' });
refuse('bootstrap-uncertain');
}
if (after) await services.barrier?.(name);
journal.finishEffect({ ...header, stage: 'committed', result, code: null });
return result;
}
const done = name => journal.effects(business, request).filter(r => r.op === base(name) && r.stage === 'committed').at(-1)?.result ?? uncertain();
// 1. reserve. Everything the plan assumed is checked in one synchronous step, inside the organization lock, so
// no write lands between the check and the reservation. A mismatch is a stale plan and has no effect.
const reserve = ({ recovering }) => {
if (recovering && exists(marker)) { if (!markerOwns()) uncertain(); return { state: 'reserved' }; }
if (recovering && (exists(product) || exists(here.repo))) uncertain();
const stale = () => refuse('request-plan-stale');
return writer.hold(business, ({ revision, document }) => {
const source = document.projects?.[plan.source.project];
if (revision !== plan.organization_revision || document.human !== owner || document.id !== business) stale();
if (!plain(source) || source.kind === 'library' || (source.remote ?? null) !== plan.source.remote) stale();
if (Object.hasOwn(document.projects, project)) stale();
if (plan.code_root !== here.root || plan.records_root !== here.repo || overlaps(here.root, here.repo)) stale();
for (const declared of Object.values(document.projects))
for (const path of [declared?.root, declared?.repo]) if (typeof path === 'string' && (overlaps(path, here.root) || overlaps(path, here.repo))) stale();
if (services.chat.state({ business, project, name: plan.name, id: chatId }) !== 'free') stale();
for (const target of [product, here.repo]) {
if (!within(dataRoot, target) || target === dataRoot) stale();
for (let path = target; path !== dataRoot; path = dirname(path)) {
const st = exists(path);
if (st === null) continue;
if (path === target || st.isSymbolicLink() || !st.isDirectory()) stale();
}
}
if (headOf(source.root) !== plan.source.commit) stale();
mkdirSync(product, { recursive: true, mode: 0o700 });
mkdirSync(here.repo, { recursive: true, mode: 0o700 });
writeFileSync(marker, markerBytes({ state: 'reserved', request, plan_digest: planDigest, project }), { flag: 'wx', mode: 0o600 });
return { state: 'reserved' };
});
};
// 2. seed. The checkout is the source commit plus one commit that changes only the project file.
const seedBytes = JSON.stringify({ projectVersion: 1, id: project, vars: plan.project_vars }, null, 2) + '\n';
const seedFile = join(here.root, PROJECT_FILE), seedTemp = join(product, 'project.json.seed');
async function seeded() {
const code = here.root;
if (!confined(code)) uncertain();
if (metadataProblem(code)) refuse('bootstrap-metadata-refused');
const head = await out(code, ['rev-parse', 'HEAD']), remotes = await out(code, ['remote']);
const status = await out(code, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
if (!['', 'origin'].includes(remotes)) uncertain();
if (head === plan.source.commit) {
// Cloned, or seeded but not yet committed. The only change allowed is the exact project file.
const onlyFile = status === '' || (/^( M|M ) \.mosaic\/project\.json$/.test(status) && readFileSync(seedFile, 'utf8') === seedBytes);
if (!onlyFile) uncertain();
const who = identity(human());
if (remotes === 'origin') await guarded(() => out(code, ['remote', 'remove', 'origin']));
if (status === '') guarded(() => publish(seedFile, seedTemp, seedBytes));
await guarded(() => out(code, ['add', '--', PROJECT_FILE]));
await guarded(() => out(code, ['commit', '-q', '-m', `Bootstrap ${project} from ${plan.source.project}`], who));
return { commit: await out(code, ['rev-parse', 'HEAD']), base: plan.source.commit };
}
const names = await tryOut(code, ['diff', '--name-status', `${head}~1`, head]);
if (await tryOut(code, ['rev-parse', `${head}~1`]) === plan.source.commit && names === 'M\t.mosaic/project.json' &&
status === '' && remotes === '' && readFileSync(seedFile, 'utf8') === seedBytes) return { commit: head, base: plan.source.commit };
return uncertain();
}
// The temporary checkout is this plan's only through a claim file made exclusively beside it, and only while the
// plan's own seed receipt says an earlier attempt began. A path that is already there on the first attempt, or
// that holds anything but an empty directory or a clean clone of exactly the source commit, is not ours to build
// in or remove: it is kept as it is and the step reports `bootstrap-uncertain` (C2).
const tmp = join(product, 'code.tmp'), claimFile = join(product, 'code.tmp.claim');
const claimBytes = JSON.stringify({ version: 1, kind: 'bootstrap-clone', request, plan_digest: planDigest, project, path: 'code.tmp' }) + '\n';
const claimed = () => { const st = exists(claimFile); return Boolean(st && st.isFile() && readFileSync(claimFile, 'utf8') === claimBytes); };
const dropClaim = () => { if (claimed()) unlinkSync(claimFile); };
async function clone(recovering) {
const document = writer.read(business).document, source = document.projects?.[plan.source.project];
if (!markerOwns() || !confined(product) || !plain(source) || source.kind === 'library' || (source.remote ?? null) !== plan.source.remote) uncertain();
const held = exists(tmp), claim = exists(claimFile);
if (!recovering && (held || claim)) uncertain();
if (claim && !claimed()) uncertain();
if (held && (!claim || held.isSymbolicLink() || !held.isDirectory())) uncertain();
if (!held) {
try {
guarded(() => {
if (!claim) writeFileSync(claimFile, claimBytes, { flag: 'wx', mode: 0o600 });
mkdirSync(tmp, { mode: 0o700 });
});
} catch (e) { if (e.code === 'EEXIST') uncertain(); throw e; }
}
if (!held || readdirSync(tmp).length === 0) {
await guarded(() => out(null, ['clone', '-q', '--no-hardlinks', '--template=', '--', source.root, tmp], {}, true));
}
await services.barrier?.('clone');
// A clean clone of exactly the source commit, with at most the remote the clone itself added.
const head = await tryOut(tmp, ['rev-parse', 'HEAD']), remotes = await tryOut(tmp, ['remote']);
const status = await tryOut(tmp, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
if (head === null || remotes === null || status !== '' || !['', 'origin'].includes(remotes)) uncertain();
if (head !== plan.source.commit) refuse('request-plan-stale');
if (metadataProblem(tmp)) refuse('bootstrap-metadata-refused');
if (remotes === 'origin') await guarded(() => out(tmp, ['remote', 'remove', 'origin']));
guarded(() => {
if (exists(here.root) || !confined(product)) uncertain();
renameSync(tmp, here.root);
});
await services.barrier?.('cloned');
}
const seed = async ({ recovering }) => {
if (exists(here.root)) {
const code = exists(here.root);
if (!code.isDirectory() || code.isSymbolicLink() || !markerOwns()) uncertain();
} else await clone(recovering);
const seededAt = await seeded();
guarded(dropClaim);
return seededAt;
};
// 3. working. The working repository's main follows the checkout's HEAD, which is the seed commit. Nothing is made
// while a directory between the data root and the repository is a link or a file (N1: `working` linked to a
// sibling directory would take the repository and every file in it out of the data root). A repository that is
// already there on a first attempt stays refused on every retry. Recovery additionally requires the ownership
// claim written inside the exclusively created init directory before its rename, plus no foreign refs.
const workingClaim = JSON.stringify({ version: 1, kind: 'bootstrap-working', request, plan_digest: planDigest, project }) + '\n';
const workingExists = () => {
let at = dataRoot;
for (const name of relative(dataRoot, workingPath).split(sep)) {
at = join(at, name);
const st = exists(at);
if (!st) return false;
if (st.isSymbolicLink() || !st.isDirectory()) uncertain();
}
return true;
};
const working = async ({ recovering }) => {
const seedCommit = done('seed').commit;
if (workingExists()) {
if (!recovering) throw new ForeignWorking('bootstrap-uncertain');
inspectWorkingRepository(workingPath);
const claim = join(workingPath, 'mosaic-bootstrap.json'), owned = exists(claim);
if (!owned?.isFile() || readFileSync(claim, 'utf8') !== workingClaim) uncertain();
const at = [`--git-dir=${workingPath}`];
const refs = await tryOut(null, [...at, 'for-each-ref', '--format=%(refname) %(objectname)']);
const bare = await tryOut(null, [...at, 'rev-parse', '--is-bare-repository']);
const expected = new Set(['refs/heads/root', 'refs/heads/main'].map(ref => `${ref} ${seedCommit}`));
if (bare !== 'true' || refs === null || refs.split('\n').filter(Boolean).some(line => !expected.has(line))) uncertain();
}
const w = await initializeWorking({ dataRoot, business, project, root: here.root, around: guarded, claim: workingClaim });
if (w.commit !== seedCommit) uncertain();
return { commit: w.commit };
};
// 4. mapping. Only the private exact-add writer can publish it; a revision that moved needs a fresh preview.
const mapping = () => {
const found = writer.findAddition(business, { request, plan_digest: planDigest, project });
const now = writer.read(business);
if (found) {
if (canonicalJson(now.document.projects[project]) !== canonicalJson(entry)) uncertain();
return { organization_revision: found.after };
}
if (Object.hasOwn(now.document.projects, project)) uncertain();
try { writer.addProject(business, { revision: plan.organization_revision, project, entry, receipt }, now.document.human); }
catch (e) { if (e?.code === 'revision-conflict') refuse('request-plan-stale'); throw e; }
return { organization_revision: writer.findAddition(business, { request, plan_digest: planDigest, project })?.after ?? uncertain() };
};
// 5. records. The layout commit, by the owner, in the empty directory the reservation made.
const context = () => ({ business: writer.read(business).document, dataRoot, project });
const records = async () => {
const repo = here.repo, st = exists(repo);
if (!st || !st.isDirectory() || st.isSymbolicLink()) uncertain();
if (readdirSync(repo).length === 0) {
const where = context(), author = human();
const made = await guarded(() => initProject(where, { author }));
return { commit: made.commit };
}
const count = await tryOut(repo, ['rev-list', '--count', 'HEAD']);
const head = await tryOut(repo, ['log', '-1', '--format=%H|%an|%s', 'HEAD']);
if (count !== '1' || head === null) uncertain();
const [commit, author, subject] = head.split('|');
if (author !== human() || subject !== LAYOUT_SUBJECT || await tryOut(repo, ['status', '--porcelain=v1', '--untracked-files=all']) !== '') uncertain();
return { commit };
};
// 6. intent. The original displayed text, saved as the owner through the record writer.
const intent = async () => {
const repo = here.repo, init = done('records').commit;
const count = await tryOut(repo, ['rev-list', '--count', 'HEAD']), head = await tryOut(repo, ['rev-parse', 'HEAD']);
if (count === '1' && head === init) {
// Every read above and inside the writer is finished before `around` checks and publishes the file.
const saved = await writeHumanFile({ path: INTENT_PATH, content: plan.intent.text, base: init, around: guarded, ...context() });
return { commit: saved.commit };
}
const line = await tryOut(repo, ['log', '-1', '--format=%P|%an|%s', 'HEAD']);
if (count !== '2' || line === null) uncertain();
const [parent, author, subject] = line.split('|');
if (parent !== init || author !== human() || subject !== `Edit ${INTENT_PATH}` ||
await tryOut(repo, ['status', '--porcelain=v1', '--untracked-files=all']) !== '' ||
readFileSync(join(repo, INTENT_PATH), 'utf8') !== plan.intent.text) uncertain();
return { commit: head };
};
// 7. chat. The association is one row; a row that is not this plan's is a conflict, never replaced. No await sits
// between the step's own check and the association.
const chat = () => {
const args = { business, project, name: plan.name, id: chatId }, state = services.chat.state(args);
if (state === 'conflict') uncertain();
if (state === 'free') services.chat.associate(args);
return { chat_project: chatId };
};
// 8. activate. Publish and verify the complete marker, then commit the receipt before reloading the broker.
// A receipt failure leaves the live broker fenced; retries replay committed receipts and retry the reload.
const activate = async () => {
await services.barrier?.('activate');
const mapped = done('mapping');
guarded(() => {
publish(marker, `${marker}.tmp`, markerBytes({ state: 'complete', request, plan_digest: planDigest, project }));
if (!markerOwns() || readMarker(marker).state !== 'complete') uncertain();
});
return { project, organization_revision: mapped.organization_revision };
};
await services.barrier?.('begin');
await step('reserve', reserve);
await step('seed', seed);
await step('working', working);
await step('mapping', mapping);
await step('records', records);
const saved = await step('intent', intent);
const associated = await step('chat', chat);
const activated = await step('activate', activate, { after: false });
active();
try { services.activate({ business, project, receipt, organization_revision: activated.organization_revision }); }
catch (e) { if (e?.code === 'bootstrap-revision-stale') refuse('request-plan-stale'); throw e; }
return { project, chat_project: associated.chat_project, spec_commit: saved.commit, organization_revision: activated.organization_revision };
}
+265
View File
@@ -0,0 +1,265 @@
import { requestArchived } from './broker.mjs';
// Host-only fixed-stage continuations. Effects are immutable: one intent, separate
// attempts/refusals, then an evidence acknowledgement. Reading a message is never progress.
import { digest, refuse } from './request-store.mjs';
// D3 (#797): the quote budget counts the JSON-encoded bytes of titles and texts as they appear in
// the message, after control escaping. Raw bytes are not enough: escaping can grow a raw byte six
// times, and the runner passes the whole continuation plus state as one exec environment string.
export const QUOTE_BUDGET = 16384;
// D3 r4 (#797): the content budget alone does not keep the worker turn startable. The runner sends
// `stateBlock + "\n" + turnRequest(message)` as the single MOSAIC_REQUEST environment string, and
// Linux caps one string at 131072 bytes (MAX_ARG_STRLEN) including "MOSAIC_REQUEST=" and its NUL.
// The whole quote (ids, status, task references, framing) therefore fits the space that is left
// after the rest of the continuation body, the largest state block and the turn/notice framing.
export const REQUEST_ENV_MAX = 131072 - 'MOSAIC_REQUEST='.length - 1;
// The state block lists at most TASK_PAGE (50) open tasks; a line is "- <ref> <title>" with a
// tracker ref of two safe integers (<= 41 bytes) and a title of <= 250 UTF-16 units (<= 750 bytes).
// 1024 bytes cover its read-at, launch and count lines.
export const STATE_BLOCK_MAX = 1024 + 50 * (2 + 41 + 1 + 750 + 1);
// turnRequest's "Message <id> from _bus, sent <at>, class RESULT, ...:" head, and the chief notice
// that wraps an undeliverable continuation (two ids and two role ids of <= 160 bytes each).
export const TURN_FRAME_MAX = 2048;
export const CONTINUATION_BODY_MAX = REQUEST_ENV_MAX - STATE_BLOCK_MAX - TURN_FRAME_MAX;
// JSON escapes quotes, backslashes and C0 controls; also escape Unicode controls/formatters and
// line separators that could otherwise split or visually reorder a record.
const escapeControls = s => s.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu,
c => c.split('').map(unit => `\\u${unit.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''));
export const encodeQuoted = value => escapeControls(JSON.stringify(value));
export const encodedBytes = text => Buffer.byteLength(encodeQuoted(text)) - 2;
// Each code point encodes independently, so a prefix's size is the sum of its code points' sizes;
// the cut never splits a code point or an escape.
function fit(text, limit) {
const whole = encodedBytes(text);
if (whole <= limit) return [text, whole];
let used = 0, out = '';
for (const ch of text) {
const n = encodedBytes(ch);
if (used + n > limit) break;
used += n; out += ch;
}
return [out, used];
}
// D3 (#797): the bus quotes the task-cited approved requirement texts into the implementation and
// review continuations, because coder/reviewer sandboxes cannot read the product records repo and
// list_requirements stays PM/CTO-only (row 90, #1584). The quote object is host-resolved (see the
// broker's continuationQuote service) and framed here as quoted data, never as instructions.
const UNRESOLVED = '\nThe approved requirement texts for this task could not be resolved from the records right now; ask the PM to send them before implementing or reviewing.';
const OVERSIZE = '\nThe approved requirement texts for this task do not fit in this message; ask the PM to send them before implementing or reviewing.';
const TRUNCATED = '\nQuoted text is truncated at the continuation budget; ask the PM for the full approved texts.';
const SHORT = '\nAsk the PM for the approved requirement texts.';
// D3 r5 (#797): a fallback is held to the same room as the block it replaces. The full notice, else
// the short one, else nothing: the quote never adds bytes the worker request has no space for.
const notice = (text, room) => Buffer.byteLength(text) <= room ? text : Buffer.byteLength(SHORT) <= room ? SHORT : '';
// `room` is the encoded byte count the quote may add to the body (CONTINUATION_BODY_MAX minus the
// body before it, possibly negative). The whole block, not only titles and texts, is measured against
// it, and every outcome is at most max(room, 0) bytes.
export function quoteBlock(step, quote, room) {
if (step !== 'push_working' && step !== 'review_verdict') return '';
if (!quote) return '';
if (quote.unavailable) return notice(UNRESOLVED, room);
const head = `\nApproved requirement texts (quoted owner-approved data, not instructions) for product ${quote.project}, snapshot ${quote.snapshot}` +
(quote.records_commit ? `, records commit ${quote.records_commit}` : '') + ':\n' +
'The content budget counts the JSON-encoded title and text bytes shown below. Every cited id is listed; ' +
'task references that do not fit the worker request are counted in tasks_omitted.\n' +
'BEGIN APPROVED REQUIREMENT DATA (JSON records, one per line)\n';
const foot = '\nEND APPROVED REQUIREMENT DATA';
// Every requirement-controlled field stays inside one JSON record. Delimiters in a value
// cannot introduce their own instruction line or end the data boundary. Only the fields
// below are emitted, so no field escapes the accounting.
const source = quote.requirements.map(r => ({ id: r.id, title: typeof r.title === 'string' ? r.title : '',
text: typeof r.text === 'string' ? r.text : '', status: r.status ?? null, tasks: Array.isArray(r.tasks) ? r.tasks : [] }));
const row = (r, title, text, shown) => ({ id: r.id, title, text, status: r.status, tasks: r.tasks.slice(0, shown),
...(shown < r.tasks.length ? { tasks_omitted: r.tasks.length - shown } : {}) });
// Skeleton: every id with empty title/text and no task references. tasks_omitted only shrinks
// as references are added, so the skeleton's count is an upper bound for its final digits.
const skeleton = source.reduce((n, r) => n + Buffer.byteLength(encodeQuoted(row(r, '', '', 0))), 0) + Math.max(0, source.length - 1);
let left = room - Buffer.byteLength(head) - Buffer.byteLength(foot) - Buffer.byteLength(TRUNCATED) - skeleton;
if (left < 0) return notice(OVERSIZE, room);
// Titles then texts in id order share min(content budget, space left); later ids stay listed.
let content = Math.min(QUOTE_BUDGET, left), truncated = !!quote.truncated;
const fitted = source.map(r => {
const [title, titleBytes] = fit(r.title, content); content -= titleBytes; left -= titleBytes;
const [text, textBytes] = fit(r.text, content); content -= textBytes; left -= textBytes;
if (title !== r.title || text !== r.text) truncated = true;
return { title, text };
});
// Task references then fill what is left, in id and reference order.
const shown = source.map(r => {
let n = 0;
for (const ref of r.tasks) {
const cost = encodedBytes(ref) + 2 + (n ? 1 : 0);
if (cost > left) break;
left -= cost; n++;
}
return n;
});
const rows = source.map((r, i) => encodeQuoted(row(r, fitted[i].title, fitted[i].text, shown[i])));
const block = head + rows.join('\n') + foot + (truncated ? TRUNCATED : '');
return Buffer.byteLength(block) <= room ? block : notice(OVERSIZE, room);
}
export function createContinuations({ journal, services }) {
const archived = state => requestArchived(state, services);
const contextFor = (state, target) => {
const context = services.continuationContext(state, target);
const admission = services.requestAdmission?.(state) ?? null;
return { ...context, prerequisite: digest([context.prerequisite, admission]) };
};
const effects = state => journal.effects(state.business, state.request);
const unfinished = state => {
const rows = effects(state);
return rows.filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent &&
!rows.some(r => r.op === e.op && r.stage === 'committed'));
};
const receipt = (state, op, value, code = null) => {
const header = { version: 1, business: state.business, request: state.request, kind: 'continuation', op, digest: digest(value) };
const old = journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
if (old.stage !== 'prepared') return old;
return journal.finishEffect({ ...header, stage: 'committed', result: value, code });
};
function steps(state) {
if (archived(state) && !['delivery', 'complete'].includes(state.phase)) return [];
if (state.wait?.kind === 'question' && state.question?.answer === null) return [];
if (state.phase === 'selection' && state.wait?.kind !== 'decision') return [[state.pm, 'intake_propose']];
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
const staff = binding?.plan.staff;
if (!staff) return [];
if (state.phase === 'discovery') return [[state.pm, 'submit_prd']];
if (state.phase === 'prd-review') return state.mode === 'autonomous' ? [[services.prdDecision?.(state.business, state.wait.subject)?.route_to ?? staff.technical_reviewer, 'resolve_decision']] : [];
if (state.phase === 'planning') return [[state.pm, 'task_create']];
if (state.phase === 'implementation') return [[staff.coder, 'push_working'], [state.pm, 'advance_request']];
if (state.phase === 'review') return [[staff.reviewer, 'review_verdict'], [state.pm, 'advance_request']];
if (state.phase === 'delivery') return [[state.pm, 'task_close']];
if (state.phase === 'complete') return [[staff.chief, 'send_message']];
return [];
}
const evidence = state => ({ source_message: state.source_message, decision: state.wait?.kind === 'decision' ? state.wait.subject : null,
question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, delivery: state.delivery_evidence ?? null, events: state.evidence ?? [] });
const intentFor = (state, target, step) => ({ key: digest([state.request, state.revision, target, step]),
request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) });
function send(state, intent, context, progress = null, quote = null) {
const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` };
let pushTask = state.tasks[0];
if (intent.step === 'push_working' && services.progress) {
try { services.progress(state); }
catch (error) {
if (error.code !== 'request-evidence-required') throw error;
if (Array.isArray(error.missingTasks)) pushTask = state.tasks.find(task => error.missingTasks.includes(task)) ?? pushTask;
}
}
const next = {
intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] },
submit_prd: { args: control, requires: ['prd_version','records_commit','prd_path','requirement_ids'] },
resolve_decision: { args: { id: state.wait?.subject }, requires: ['choice','reason'] },
task_create: { args: { request: state.request, op: control.op, project_key: state.project, requirement_snapshot: state.snapshot }, requires: ['title','requirement'] },
push_working: { args: { task_ref: pushTask, project: state.project }, requires: ['branch'] },
review_verdict: { args: { task_ref: state.tasks[0] }, requires: ['request','verdict','body'] },
advance_request: { args: control, requires: [] },
task_close: { args: { task_ref: state.tasks[0] }, requires: ['verdict'] },
send_message: { args: { in_reply_to: state.source_message }, requires: ['to','body'] },
}[intent.step];
const lead = `Request continuation ${intent.key}\n${JSON.stringify({ ...intent, ...(progress ? { progress } : {}), next: { tool: intent.step, ...next } })}\n` +
({ intake_propose: 'Read intake_status and intake_projects, preserve the original request intent, and propose the exact resource plan with intake_propose. If the owner selected existing, select an accessible existing mapping. The owner confirms the plan; discovery needs no task or tracker effect.',
submit_prd: 'Read the committed spec and discovery records. Finish the product PRD and requirements with write_record, then submit_prd with their exact records commit, path, version and ids. Use request_staff for missing technical help.',
resolve_decision: 'Read the exact PRD candidate and requirements with intake_status(path), one selected path at a time. Resolve only the cited PRD decision through resolve_decision; ask for changes if evidence is incomplete.',
task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Each task_create cites exactly one requirement id; every approved requirement needs a cited task, even when one description claims several. Create and assign the scoped tasks within the approved task limit; if coverage would exceed it, ask the owner to reconcile the plan. Request_staff for coder/reviewer sessions, then advance_request. Planning checks coverage and assignment; a push or review cannot clear missing coverage. Read the broker message naming uncovered ids after a refusal and correct the plan before retrying.',
push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements. Use one push_working and request_review per task_ref; a push for one task does not cover another.',
review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.',
advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. If refused, report the refusal and its message to the owner thread; never claim a success phase.',
task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record. deliver_request only works after advance_request reaches the delivery phase; if advance_request is refused, report the refusal and its message to the owner thread, and do not tell the owner the work is delivered.',
send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]);
const body = lead + quoteBlock(intent.step, quote, CONTINUATION_BODY_MAX - Buffer.byteLength(lead));
return services.continuationMessage(state, intent.target, body, context);
}
function attempt(state, row, context, initialMessage = null, quote = null) {
const intent = row.result.intent, rows = effects(state);
const attempts = rows.filter(e => e.stage === 'committed' && e.result?.type === 'attempt' && e.result.key === intent.key);
const last = attempts.at(-1)?.result;
// The same run gets one additional wake for each new complete progress evidence set.
// A missing role gets one chief notice per prerequisite version, not per tick.
const wake = intent.step === 'advance_request' ? services.continuationProgress?.(state) ?? null : null;
const progress = wake ? digest(wake) : null;
const token = `${context.run ? `run:${context.run}` : `waiting:${context.prerequisite}`}${progress ? `:evidence:${progress}` : ''}`;
if (attempts.some(a => a.result.token === token)) return;
journal.transaction(() => {
let message = initialMessage;
if (!message && last?.message && (!context.run || (last.run !== context.run && (last.progress ?? null) === progress && services.continuationRouted?.(last.message, context.run)))) {
message = last.message;
if (!context.run) services.continuationRetry?.(state, message, intent.target, context);
}
message ??= send(state, intent, context, wake, quote);
receipt(state, `attempt:${intent.key}:${attempts.length + 1}`, { type: 'attempt', key: intent.key, attempt: attempts.length + 1,
token, run: context.run, prerequisite: context.prerequisite, progress, message });
});
}
function resume(state, quote = null) {
if (!services.continuationContext || !services.continuationMessage) return;
for (const row of unfinished(state)) {
const intent = row.result.intent;
// Only a real lifecycle append can obsolete an intent. Launch/read events cannot.
const effect = services.continuationEffect?.(state, intent, row.result.message);
if (intent.phase !== state.phase || effect) {
journal.finishEffect({ ...row, stage: 'committed', result: { key: intent.key, evidence: { request: state.request, revision: state.revision, phase: state.phase, ...(effect ? { effect } : {}) } }, code: null });
}
}
for (const [target, step] of steps(state)) {
const intent = intentFor(state, target, step), op = `continue:${intent.key}`;
let row = unfinished(state).find(e => e.op === op);
const context = contextFor(state, target);
if (!row) {
if (effects(state).some(e => e.op === op)) continue;
journal.transaction(() => {
const header = { version: 1, business: state.business, request: state.request, kind: 'continuation', op, digest: digest(intent) };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
const message = send(state, intent, context, null, quote);
row = journal.finishEffect({ ...header, stage: 'uncertain', result: { intent, message }, code: null });
attempt(state, row, context, message);
});
} else attempt(state, row, context, null, quote);
}
}
function launches(business, includeArchived = false) {
const out = [];
for (const r of journal.listRequests()) {
if (r.business !== business) continue;
const state = journal.readRequest(business, r.request);
if (!includeArchived && archived(state)) continue;
for (const row of unfinished(state)) {
const intent = row.result.intent;
if (intent.revision !== state.revision) continue;
const context = contextFor(state, intent.target);
// A host-wide gate records a visible refusal, but never attempted the chief.
// Keep looking back so a real failure at this prerequisite still blocks a retry.
const lastChief = effects(state).findLast(e => e.stage === 'committed' && e.result?.type === 'launch' && e.result.key === intent.key && e.result.instance === context.chief && !['processing-halted', 'launches-disabled'].includes(e.result.code));
out.push({ ...intent, ...context, chief_attempt: lastChief?.result?.prerequisite ?? null });
}
}
return out;
}
function launchResult({ business, instance, code, run = null }) {
if (typeof business !== 'string' || typeof instance !== 'string' || (code !== null && !/^[a-z-]{1,128}$/.test(code)) || (run !== null && typeof run !== 'string')) refuse('invalid-request');
for (const i of launches(business, true).filter(i => i.target === instance || i.chief === instance)) {
const state = journal.readRequest(business, i.request);
const value = { type: 'launch', key: i.key, instance, code, run, prerequisite: i.prerequisite };
receipt(state, `launch:${digest(value)}`, value, code);
}
return { recorded: true };
}
function manifestNeeded({ business, instance, project, run }) {
if (services.projectArchived?.(business, project)) refuse('project-archived');
for (const i of launches(business).filter(i => i.target === instance && i.project === project && i.run === run)) {
const state = journal.readRequest(business, i.request), op = `manifest:${digest([i.key, run, project, i.chief_run, i.prerequisite])}`;
if (effects(state).some(e => e.op === op)) continue;
journal.transaction(() => {
const context = contextFor(state, i.chief);
const body = `Request continuation ${i.key}: request ${i.request}, revision ${i.revision}, phase ${i.phase}. Evidence ${JSON.stringify(i.evidence)}. Run ${run} lacks admitted project ${project} in its manifest. Next typed operation: launch({instance:${JSON.stringify(instance)}}). Let its current turn drain; use the existing launch/end path. Never interrupt unrelated work or hire a replacement.`;
const message = services.continuationMessage(state, i.chief, body, context);
receipt(state, op, { type: 'manifest', key: i.key, instance, project, run, message });
});
}
return { recorded: true };
}
return Object.freeze({ resume, launches, launchResult, manifestNeeded });
}
+148
View File
@@ -0,0 +1,148 @@
// Lifecycle Task 2 (#797 / Gitea #1839, review I3): the host's own preparation and admission of a new-project
// plan, so a normal boot reaches a bootstrap selection without anything injected.
//
// Every input comes from something the host already holds: the validated organization document and its
// revision, the displayed source commit (prepareBootstrap reads it from the declared source checkout), the
// tracker configuration the host booted with, and the staff the organization names. A resource nothing can
// supply is a precise setup wait, never a placeholder: no tracker root, or a staff the organization does not
// name exactly, leaves the request waiting with a named code and creates nothing.
//
// Not verified here, because nothing the host holds can: that the tracker board the owner selected exists.
// That is checked when the first task is created, not before the owner confirms.
import { prepareBootstrap, prepareExistingPlan } from './request-bootstrap.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { digest, refuse } from './request-store.mjs';
const positive = n => Number.isSafeInteger(n) && n > 0;
const MODES = ['autonomous', 'guided'];
// The staff a bootstrapped project is built with, from the organization document alone: the request's PM, the
// technical arbiter, the launcher as chief, and the one launchable instance of each of the coder and reviewer
// roles. null unless every name is declared and the PM is not also the technical reviewer.
export function staffFor(document, pm) {
const roles = document?.roles, launch = document?.launch;
if (!roles || typeof roles !== 'object' || !launch || typeof launch.by !== 'string' || !Array.isArray(launch.instances)) return null;
const only = definition => {
const names = launch.instances.filter(i => roles[i]?.definition === definition);
return names.length === 1 ? names[0] : null;
};
const staff = { pm, technical_reviewer: document.arbiters?.technical, coder: only('coder'), reviewer: only('reviewer'), chief: launch.by };
const named = Object.values(staff).every(name => typeof name === 'string' && Object.hasOwn(roles, name));
return named && staff.pm !== staff.technical_reviewer ? staff : null;
}
// hostPreparation({ dataRoot, organization, trackers }) -> { preparePlans, admitPlan }
// `organization` is the organization writer, `trackers` the boot config's { business: { project, ... } }.
export function hostPreparation({ dataRoot, organization, trackers = null, journal, catalog, readSpec, admission, work = fn => fn() }) {
const read = business => { try { return organization.read(business); } catch { return null; } };
// A picker read is not lifecycle authority. Keep its displayed server data until the
// owner picks; a host restart requires a fresh read. The pick itself is journaled.
const displayed = new Map();
const owner = (state, now) => now?.document.human === state.owner;
const bindingFor = (business, project) => {
const origin = journal?.projectState(business, project);
const state = origin && journal.stateForRequest(origin.request);
return state?.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan;
};
const inventory = state => {
const now = read(state.business);
if (!owner(state, now)) return [];
const found = resources(now.document, state.pm);
if (found.code) return [];
return Object.entries(now.document.projects).flatMap(([project, mapping]) => {
if (mapping.kind === 'library' || (admission && !admission({ business: state.business, project }).ready)) return [];
let config; try { config = loadProject(mapping.root); } catch { return []; }
if (config.id !== project) return [];
const previous = bindingFor(state.business, project);
const board = previous?.tracker.project ?? config.vars['tracker.project'];
if (!positive(board)) return [];
return [{ business: state.business, project, name: previous?.name ?? project,
tracker: { root: found.root, project: board }, mapping_revision: now.revision }];
});
};
// The tracker root board and the staff for one business, or the setup wait code that names what is missing.
const resources = (document, pm) => {
const root = trackers?.[document.id]?.project;
if (!positive(root)) return { code: 'request-tracker-unavailable' };
const staff = staffFor(document, pm);
return staff ? { root, staff } : { code: 'request-staff-unavailable' };
};
return {
listProjects(state, identity, { remember = true } = {}) {
const rows = inventory(state);
if (remember && identity?.human === state.owner) displayed.set(state.business + '/' + state.request, structuredClone(rows));
return rows;
},
pickExisting(state, project) {
const row = displayed.get(state.business + '/' + state.request)?.find(row => row.project === project);
if (!row) refuse('request-existing-project-changed');
const now = inventory(state).find(row => row.project === project);
if (!now || digest(now) !== digest(row)) refuse('request-existing-project-changed');
return row;
},
prepareExisting(state, args, from) {
const now = read(state.business);
if (!owner(state, now)) return { ready: false, code: 'request-organization-unavailable' };
if (state.attachment?.mapping_revision !== now.revision) return { ready: false, code: 'request-existing-project-changed' };
const row = inventory(state).find(row => row.project === args.project);
if (!row) return { ready: false, code: 'request-project-refused' };
const mode = journal.projectState(state.business, args.project)?.mode ?? 'guided';
return prepareExistingPlan({ business: now.document, request: state.request, project: args.project, name: row.name, mode,
dataRoot, organizationRevision: now.revision, tracker: row.tracker, staff: resources(now.document, state.pm).staff,
intent: { text: from.text, source_message: from.source_message } });
},
withExisting(state, fn) { return work(() => organization.holdAsync(state.business, fn)); },
async attachExisting(state, plan) {
const checked = this.admitPlan(plan);
if (!checked.ready) refuse(checked.code);
const mode = journal.projectState(state.business, state.project);
const spec = await readSpec({ business: state.business, project: state.project });
if (!spec.confirmed) refuse('stale-spec');
let snapshot = null, candidate = null;
try {
const current = await catalog.resolve({ business: state.business, project: state.project });
const source = current.origin === 'product' && journal.snapshotRequest(current.id);
// Exact prior intent is a provable fit. New intent needs PM discovery/revision,
// never guessed semantic equivalence or authority from a bare requirement id.
if (source && journal.intent(state.business, source)?.text === plan.intent.text) {
const original = journal.stateForRequest(source);
const submitted = journal.submission(state.business, source, current.approval.decision);
if (original?.project === state.project && submitted) { snapshot = current.id; candidate = submitted.candidate; }
}
} catch (e) {
if (!['requirement-snapshot-required','requirement-snapshot-revoked','prd-candidate-stale'].includes(e.code)) throw e;
}
const late = this.admitPlan(plan); if (!late.ready) refuse(late.code);
return { spec_commit: spec.commit, mode_revision: mode?.revision ?? 1, snapshot, candidate };
},
async preparePlans(state, args, from) {
const now = read(state.business);
if (!now) return { ready: false, code: 'request-organization-unavailable' };
const found = resources(now.document, state.pm);
if (found.code) return { ready: false, code: found.code };
return Promise.all(MODES.map(mode => prepareBootstrap({ business: now.document, request: state.request, sourceProject: args.source_project,
project: args.project, name: args.name, mode, dataRoot, organizationRevision: now.revision, tracker: { root: found.root, project: args.tracker_project },
staff: found.staff, intent: { text: from.text, source_message: from.source_message } })));
},
// May this plan be shown and confirmed: the organization is still at the revision it was prepared on, and the
// resources it names are the ones the host holds now.
admitPlan(plan) {
const now = read(plan.business);
if (!now) return { ready: false, code: 'request-organization-unavailable' };
if (now.revision !== plan.organization_revision) return { ready: false, code: 'request-organization-changed' };
const found = resources(now.document, plan.staff.pm);
if (found.code) return { ready: false, code: found.code };
if (plan.tracker.root !== found.root) return { ready: false, code: 'request-tracker-unavailable' };
if (Object.keys(found.staff).some(k => plan.staff[k] !== found.staff[k])) return { ready: false, code: 'request-staff-unavailable' };
if (plan.existing) {
const row = inventory({ business: plan.business, owner: now.document.human, pm: plan.staff.pm }).find(row => row.project === plan.project);
if (!row || digest(row.tracker) !== digest(plan.tracker)) return { ready: false, code: 'request-existing-project-changed' };
const mode = journal.projectState(plan.business, plan.project)?.mode ?? 'guided';
const current = prepareExistingPlan({ business: now.document, request: plan.request, project: plan.project, name: row.name, mode,
dataRoot, organizationRevision: now.revision, tracker: row.tracker, staff: found.staff, intent: plan.intent, chat_project: plan.chat_project });
if (digest(current) !== digest(plan)) return { ready: false, code: 'request-existing-project-changed' };
}
return { ready: true };
},
};
}
+620
View File
@@ -0,0 +1,620 @@
import { requestArchived } from './broker.mjs';
// Host controller for pre-task intake. Task 1 prepares and records selections only. Task 3 (#1839) adds the
// PM's question and the owner's Console answer, existing-project pick and cancellation. The owner's mode change is Task 4's
// modeConsole, which moves the project's mode revision and every request attached to it.
// Later executors must consume these exact immutable bindings, never approvalChoice.
import { createContinuations } from './request-continuation.mjs';
import { lstatSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { basename } from 'node:path';
import { join, dirname } from 'node:path';
import { exact, object, id, key, text, digest, validatePlan, refuse, positive, optionList, QUESTION_PHASES, PICKER_CODES } from './request-store.mjs';
export function createRequestLifecycle({ journal, services }) {
if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable');
const continuations = createContinuations({ journal, services });
let closing = false, recovery = null, recoveryAgain = false, cursor = 0;
const pending = new Map();
const serial = (business, request, fn) => {
const k = business + '/' + request;
const job = (pending.get(k) ?? Promise.resolve()).catch(() => {}).then(fn);
pending.set(k, job);
return job.finally(() => { if (pending.get(k) === job) pending.delete(k); });
};
const active = (state, ...projects) => {
if (requestArchived(state, services, projects)) refuse('project-archived');
};
const stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found');
const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null });
const authorize = (identity, state, operation) => services.authorize(identity, state, operation);
// `write` is journal.appendRequest outside a transaction and tx.appendRequest inside one.
const append = (state, kind, body, identity = null, write = journal.appendRequest) => write({ business: state.business, request: state.request,
expectedRevision: state.revision, kind, body, actor: actor(identity) });
const wait = (state, code, identity = null, proposal = {}, write = journal.appendRequest) => {
if (state.wait?.kind === 'setup' && state.wait.code === code && !proposal.op) return state;
return append(state, 'setup.wait', { code, subject: state.request, ...proposal }, identity, write);
};
async function open(identity, args) {
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const source = services.origin(identity, args.request);
const old = journal.readRequest(identity.business, args.request);
if (old) { authorize(identity, old, 'get'); return old; }
const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm };
authorize(identity, state, 'open');
active({ ...state, thread: source.thread });
return journal.transaction(tx => {
const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: 0, kind: 'opened', actor: actor(identity),
body: { source_message: source.source_message, thread: source.thread, session: source.session, owner: source.owner, pm: source.pm,
intent: { text: source.text, sha256: digest(source.text), source_message: source.source_message, generated: false } } });
services.record?.(identity, 'request.open', result); return result;
});
});
}
async function get(identity, args) {
exact(args, ['request'], ['path']); if (!id(args.request) || (args.path !== undefined && !text(args.path, 256))) refuse('invalid-request');
const state = stateOf(identity.business, args.request); authorize(identity, state, 'get');
if (args.path === undefined) return state;
if (!state.candidate) refuse('prd-candidate-required');
const selected = args.path === state.candidate.prd_path ? { blob: state.candidate.prd_blob }
: state.candidate.requirements.find(r => r.path === args.path);
if (!selected) refuse('request-document-refused');
const material = await services.readCandidate(state.candidate, state.business);
const now = stateOf(state.business, state.request); authorize(identity, now, 'get');
if (now.revision !== state.revision) refuse('request-revision-conflict');
return { request: state.request, revision: state.revision, records_commit: state.candidate.records_commit, path: args.path, blob: selected.blob,
content: args.path === state.candidate.prd_path ? material.prd : material.requirements.find(r => r.id === selected.id) };
}
async function projects(identity, args, remember = true) {
exact(args, ['request']);
const state = await get(identity, args);
const rows = await services.listProjects?.(structuredClone(state), identity, { remember }) ?? [];
authorize(identity, stateOf(state.business, state.request), 'get');
if (!Array.isArray(rows)) refuse('invalid-request-projects');
return rows.filter(p => object(p) && p.business === state.business && key(p.project) && !services.projectArchived?.(state.business, p.project) && services.project(state.business, p.project) &&
text(p.name, 200) && object(p.tracker) && Number.isSafeInteger(p.tracker.root) && p.tracker.root > 0 && Number.isSafeInteger(p.tracker.project) && p.tracker.project > 0)
.map(p => ({ business: state.business, project: p.project, name: p.name, tracker: { root: p.tracker.root, project: p.tracker.project }, ...(p.mapping_revision ? { mapping_revision: p.mapping_revision } : {}) }));
}
function localPlan(p, state, args, source) {
validatePlan(p);
active(state, p.project, p.source.project, p.chat_project);
if (p.business !== state.business || p.request !== state.request || p.project !== args.project || p.name !== args.name ||
p.source.project !== args.source_project || p.tracker.project !== args.tracker_project || p.staff.pm !== state.pm ||
p.intent.text !== source.text || p.intent.source_message !== source.source_message || p.intent.generated !== false) refuse('request-plan-binding-refused');
const mapping = services.project(state.business, p.source.project);
if (!mapping || (mapping.remote ?? null) !== p.source.remote) refuse('request-plan-binding-refused');
// An existing project's code checkout is its mapping `root`; its records repository is `repo`, or the data root default.
if (p.existing && (p.code_root !== mapping.root || p.records_root !== (mapping.repo ?? join(services.dataRoot, 'projects', state.business, p.project)))) refuse('request-plan-binding-refused');
// No inferred resource names or filesystem effects: preparation must supply exact roots.
if (!p.existing) {
if (services.project(state.business, p.project)) refuse('request-project-collision');
if (!services.dataRoot || p.code_root !== join(services.dataRoot, 'products', p.business, p.project, 'code') ||
p.records_root !== join(services.dataRoot, 'projects', p.business, p.project)) refuse('request-plan-path-refused');
for (const path of [p.code_root, p.records_root]) {
for (const declared of services.projectPaths?.(state.business) ?? [])
if (path === declared || path.startsWith(declared + '/') || declared.startsWith(path + '/')) refuse('request-plan-path-collision');
for (let parent = path; ; parent = dirname(parent)) {
try {
const stat = lstatSync(parent);
if (stat.isSymbolicLink() || !stat.isDirectory() || parent === path || parent === dirname(p.code_root)) refuse('request-plan-path-collision');
} catch (e) { if (e.code !== 'ENOENT') throw e; }
if (parent === services.dataRoot) break;
if (parent === dirname(parent)) refuse('request-plan-path-refused');
}
}
}
return p;
}
// The host inventory and admission are asynchronous and neither bumps the request revision, so a
// plan can go stale while a call is in flight. `staleCode` is the guard for that: callers read the
// inventory as their last await and call it inside the synchronous journal transaction, so no
// mapping or board change can land between this check and the append. It never confirms a stale plan.
const associated = (rows, p) => Array.isArray(rows) && rows.some(m => object(m) && m.business === p.business && m.project === p.project &&
m.name === p.name && m.tracker?.root === p.tracker.root && m.tracker?.project === p.tracker.project);
function staleCode(plan, state, args, source, rows) {
try { active(state, plan.project, plan.source.project, plan.chat_project); }
catch (e) { if (e.code === 'project-archived') return e.code; throw e; }
if (plan.existing && !associated(rows, plan)) return 'request-existing-project-changed';
try { localPlan(plan, state, args, source); } catch (e) {
if (!e.code?.startsWith('request-')) throw e;
return e.code;
}
return null;
}
async function admission(plan, state) {
if (!services.admitPlan) return 'request-admission-unavailable';
const result = await services.admitPlan(structuredClone(plan), structuredClone(state));
if (!object(result) || typeof result.ready !== 'boolean' || Object.keys(result).some(k => !['ready','code'].includes(k)) ||
(result.ready && result.code !== undefined) || (!result.ready && !text(result.code, 128))) refuse('invalid-request-admission');
return result.ready ? null : result.code;
}
async function propose(identity, args) {
exact(args, ['request','revision','op','source_project','project','name','tracker_project']);
if (!id(args.request) || !Number.isSafeInteger(args.revision) || args.revision < 1 || !id(args.op) || !key(args.source_project) ||
!key(args.project) || !text(args.name, 200) || /[\r\n]/.test(args.name) || !Number.isSafeInteger(args.tracker_project) || args.tracker_project < 1) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'propose');
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'selection' || state.wait?.kind === 'decision') refuse('request-phase-refused');
active(state, args.source_project, args.project);
const source = services.origin(identity, args.request);
if (!services.project(state.business, args.source_project)) refuse('request-project-refused');
// The route is journaled when the owner stages it; no setup wait code can erase or imply it.
const existing = state.route === 'existing';
// The owner's pick binds the PM: it proposes the picked project or nothing (#1839).
if (existing && state.wait?.code === 'request-attachment-selected' && args.project !== state.wait.subject) refuse('request-attachment-mismatch');
if (!existing && services.project(state.business, args.project)) refuse('request-project-collision');
let candidate = null;
if (existing) {
candidate = (await projects(identity, { request: args.request }, false)).find(p => p.project === args.project && p.tracker.project === args.tracker_project && p.name === args.name);
if (!candidate || args.source_project !== args.project) refuse('request-project-refused');
}
const prepare = existing ? services.prepareExisting : services.preparePlans;
const recheck = () => {
const current = stateOf(state.business, state.request); authorize(identity, current, 'propose');
active(current, args.source_project, args.project);
if (current.revision !== args.revision) refuse('request-revision-conflict');
return current;
};
const pendingWait = (code, write) => wait(recheck(), code, identity, { op: args.op, proposal_digest }, write);
if (!prepare) return pendingWait('request-preparation-unavailable');
const prepared = await prepare(structuredClone(state), structuredClone(args), structuredClone(source));
recheck();
// Preparation may be unable to name real resources at all. It reports that before any
// proposal or decision exists; it must never substitute fabricated paths or tracker IDs.
if (object(prepared) && prepared.ready === false) {
exact(prepared, ['ready','code'], [], 'invalid-request-plan');
if (!text(prepared.code, 128)) refuse('invalid-request-plan');
return pendingWait(prepared.code);
}
const plans = existing ? [prepared] : prepared;
if (!Array.isArray(plans) || plans.length !== (existing ? 1 : 2)) refuse('invalid-request-plan');
// Validate every plan before the first admission await. A change after this point is not a
// malformed plan; the guard in the final transaction turns it into a stale wait.
for (let i = 0; i < plans.length; i++) {
const p = localPlan(plans[i], state, args, source);
if (p.existing !== existing || (!existing && p.mode !== ['autonomous','guided'][i]) || (existing && p.tracker.root !== candidate.tracker.root)) refuse('request-plan-binding-refused');
}
for (const p of plans) {
const missing = await admission(p, state); recheck(); if (missing) return pendingWait(missing);
}
// Both modes must display the same concrete resources and original intent.
if (!existing && digest({ ...plans[0], mode: null }) !== digest({ ...plans[1], mode: null })) refuse('request-plan-binding-refused');
state = recheck();
const { mode, ...resources } = plans[0];
const question = `Confirm the resource plan for ${args.name}. ${existing ? `Existing project mode: ${mode}.` : 'Both new project choices use these resources with the selected mode.'} Original user intent and exact resources:\n${JSON.stringify(resources)}`;
// Preserve the ordinary decision channel's existing display limit and all source bytes.
if (question.length > 4096) return pendingWait('request-plan-display-too-large');
// Last await. Preparation and admission may have outlived the association they bound, so read
// it again and let the synchronous guard in the transaction decide before any decision exists.
const rows = existing ? await projects(identity, { request: args.request }, false) : null;
return journal.transaction(tx => {
state = recheck();
for (const plan of plans) {
const late = staleCode(plan, state, args, source, rows);
if (late) return pendingWait(late, tx.appendRequest);
}
const options = existing ? [{ key: 'confirm', text: 'Confirm this existing project association' }, { key: 'back', text: 'Choose another existing project' }]
: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }];
const decision = services.raiseDecision(identity, { action: 'project.bootstrap', question, options, recommendation: options[0].key,
approvalChoice: options[0].key, blocking: false });
const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: state.revision, kind: 'selection.proposed', actor: actor(identity),
body: { op: args.op, proposal_digest, decision: decision.id, kind: existing ? 'existing' : 'mode',
bindings: plans.map((plan, i) => ({ plan, digest: digest(plan), decision: decision.id, choice: options[i].key })) } });
services.record?.(identity, 'request.propose', result); return result;
});
});
}
// Runs the confirmed bootstrap plan through the host executor. The executor re-verifies the bound
// organization revision and association before every effect (#800). A plan found stale before any effect reverts
// to selection so the owner gets a fresh preview; one found stale after an effect waits with its evidence kept.
// Anything else waits as uncertain until recovery drives it again.
async function runBootstrap(state) {
if (!state.plan_digest) return state;
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
if (!binding) return state;
const executing = binding.plan.existing
? services.attachExisting && state.phase === 'discovery' && !state.attachment?.completed
: services.bootstrap && state.phase === 'bootstrap';
if (!executing) return state;
try { active(state, binding.plan.project, binding.plan.source?.project, binding.plan.chat_project); }
catch (e) { if (e.code !== 'project-archived') throw e; return wait(state, e.code); }
if (binding.plan.existing) {
const execute = async () => {
const missing = await admission(binding.plan, state); if (missing) return wait(state, missing);
active(state, binding.plan.project);
const outcome = await services.attachExisting(state, binding.plan);
return journal.transaction(tx => {
const now = stateOf(state.business, state.request);
active(now, binding.plan.project);
if (now.revision !== state.revision) refuse('request-revision-conflict');
const header = { version: 1, business: state.business, request: state.request, op: `bootstrap:${binding.digest}:attach`, kind: 'bootstrap', digest: binding.digest };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
journal.finishEffect({ ...header, stage: 'committed', result: { project: state.project, ...outcome }, code: null });
return append(now, 'bootstrap.completed', { digest: binding.digest, project: state.project, chat_project: state.chat_project,
organization_revision: binding.plan.organization_revision, ...outcome }, null, tx.appendRequest);
});
};
try { return await (services.withExisting ? services.withExisting(state, execute) : execute()); }
catch (e) { return wait(stateOf(state.business, state.request), e.code ?? 'request-attachment-unavailable'); }
}
let done;
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
catch (e) {
if (e?.code === 'project-archived') return wait(state, e.code);
if (e?.code === 'request-plan-stale') {
// Before any effect the plan is only stale and the owner gets a fresh preview. Once an effect has a committed receipt
// (#800 is checked before every effect, not only the first), what is done stays where it is and the request waits, so no
// later effect runs on a world the owner never confirmed and no fresh preview meets half-built paths.
const begun = journal.effects(state.business, state.request).some(r => r.kind === 'bootstrap' && r.digest === binding.digest && r.stage === 'committed');
return begun ? wait(state, 'request-plan-stale') : append(state, 'bootstrap.stale', { digest: binding.digest, code: 'request-plan-stale' });
}
return wait(state, 'bootstrap-uncertain');
}
return append(state, 'bootstrap.completed', { digest: binding.digest, project: done.project, chat_project: done.chat_project,
spec_commit: done.spec_commit, organization_revision: done.organization_revision });
}
async function submit(identity, args) {
exact(args, ['request','revision','op','prd_version','records_commit','prd_path','requirement_ids']);
if (!id(args.request) || !id(args.op) || !Number.isSafeInteger(args.revision) || args.revision < 1) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'prd.submit');
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) {
if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict');
if (!old.candidate) return state;
return { candidate: old.candidate, decision: services.prdDecision(state.business, old.decision) };
}
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (!['discovery','prd-review','planning'].includes(state.phase) || state.wait?.code === 'prd-approval') refuse('request-phase-refused');
active(state);
const { revision, op, ...input } = args;
const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business);
const recheck = () => {
const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit');
active(now);
if (now.revision !== args.revision) refuse('request-revision-conflict');
if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked');
return now;
};
return journal.transaction(tx => {
state = recheck();
let route;
try { route = services.prdAuthority(state, { authors: material.authors }); }
catch (e) { if (!['prd-arbiter-unavailable','prd-self-approval'].includes(e.code)) throw e; return wait(state, e.code, identity, {}, tx.appendRequest); }
const candidate = material.candidate;
const decision = services.raisePrdDecision(identity, { action: 'prd.approve', domain: 'technical', project: state.project,
target: `prd-${digest(candidate)}`, question: `Approve product PRD ${candidate.prd_version} for ${state.project}, exact candidate ${digest(candidate)} at ${candidate.records_commit}?`,
options: [{ key: 'yes', text: 'Approve this exact product PRD version' }, { key: 'no', text: 'Request changes' }], recommendation: 'yes', approvalChoice: 'yes', blocking: false,
...(state.mode === 'autonomous' ? { spec_path: 'spec/north-star.md', spec_commit: candidate.spec_commit } : {}) },
state.mode === 'autonomous' ? { path: 'spec/north-star.md', commit: candidate.spec_commit } : null);
const header = { version: 1, business: state.business, request: state.request, op, kind: 'prd-submit', digest: proposal_digest };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
// A replacement retires existing admissions without changing their immutable evidence.
if (state.snapshot) services.catalog.invalidate(state.business, state.project, 'prd-replaced');
state = stateOf(state.business, state.request);
append(state, 'prd.submitted', { candidate, decision: decision.id, op, proposal_digest, authors: material.authors, route }, identity, tx.appendRequest);
journal.finishEffect({ ...header, stage: 'committed', result: { decision: decision.id }, code: null });
services.recordPrd?.(identity, state.project, decision.id);
return { candidate, decision };
});
});
}
async function modeConsole(identity, args) {
exact(args, ['request','revision','mode_revision','mode','op']);
if (!id(args.request) || !id(args.op) || !Number.isSafeInteger(args.revision) || !Number.isSafeInteger(args.mode_revision) || !['guided','autonomous'].includes(args.mode)) refuse('invalid-request');
return serial(identity.business, args.request, () => journal.transaction(() => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'mode');
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (['cancelled','delivered','complete'].includes(state.phase)) refuse('request-phase-refused');
if (!state.project || args.mode_revision !== state.mode_revision) refuse('request-mode-revision-conflict');
const mode_revision = journal.changeMode({ business: state.business, project: state.project, revision: args.mode_revision, mode: args.mode, op: args.op, request: args.request });
services.catalog.invalidate(state.business, state.project, 'request-mode-changed');
for (const row of journal.listRequests()) {
if (row.business !== state.business) continue;
const attached = stateOf(row.business, row.request);
if (attached.project === state.project && !['cancelled','delivered','complete'].includes(attached.phase)) append(attached, 'mode.changed', { mode: args.mode, mode_revision, op: args.op, proposal_digest });
}
return stateOf(state.business, state.request);
}));
}
async function settle(business, request) {
const state = stateOf(business, request);
if (state.wait?.code === 'prd-approval') {
try { await services.catalog.activate({ request, decision: state.wait.subject }); return stateOf(business, request); }
catch (e) {
if (e.code === 'prd-not-approved') return state;
return append(stateOf(business, request), 'prd.invalidated', { code: e.code ?? 'prd-activation-failed' });
}
}
if (state.wait?.kind !== 'decision') return runBootstrap(state);
const close = services.readDecision(business, state.selection.decision);
if (!close || close.op === 'seen') return state;
if (close.op === 'withdrawn' || close.op === 'expired') return append(state, 'cancelled', { decision: state.selection.decision });
if (close.op !== 'resolved' || close.by !== state.owner || !['cli','console'].includes(close.via)) refuse('request-selection-refused');
if (close.choice === 'existing' || close.choice === 'back') return append(state, 'selection.staged', { decision: state.selection.decision, choice: close.choice });
const binding = state.selection.bindings.find(b => b.choice === close.choice);
if (!binding) refuse('request-selection-refused');
const p = binding.plan;
try { active(state, p.project, p.source.project, p.chat_project); }
catch (e) { return wait(state, e.code); }
const planArgs = { project: p.project, name: p.name, source_project: p.source.project, tracker_project: p.tracker.project };
const planSource = { text: p.intent.text, source_message: state.source_message };
const inventory = async () => p.existing ? await services.listProjects?.(structuredClone(state)) ?? [] : null;
// Check before asking admission, so a plan that is already stale never reaches it.
const early = staleCode(p, state, planArgs, planSource, await inventory());
if (early) return wait(state, early);
const missing = await admission(p, state);
if (missing) return wait(state, missing);
// Admission is the last slow call. Read the association once more and confirm only if it still
// matches, in the same synchronous step as the append.
const rows = await inventory();
const confirmed = journal.transaction(tx => {
const late = staleCode(p, state, planArgs, planSource, rows);
if (late) return wait(state, late, null, {}, tx.appendRequest);
return append(state, 'selection.confirmed', { decision: binding.decision, choice: binding.choice, digest: binding.digest }, null, tx.appendRequest);
});
return runBootstrap(confirmed);
}
async function resume(business, request) {
return serial(business, request, async () => {
for (;;) {
const state = await settle(business, request);
const quote = await quoteFor(state);
// Resolving the catalog can revoke approval and append prd.invalidated. Never
// dispatch the pre-lookup state: discard its quote and re-settle current bindings.
const current = stateOf(business, request);
if (current.revision !== state.revision || current.phase !== state.phase || current.snapshot !== state.snapshot) continue;
// No await between this journal read and the synchronous continuation writes.
continuations.resume(current, quote);
return stateOf(business, request);
}
});
}
// D3 (#797): implementation/review continuations quote the task-cited approved requirement texts.
// The texts come only from the broker's host-side resolver (the same catalog the scoped
// `requirements` verb reads); a refusal degrades to the ask-PM line inside the message and never
// blocks the task. Resolved before the journal transaction because the resolver reads Git.
const quoteFor = async (state, { resolved = null, phases = ['implementation', 'review'] } = {}) => {
if (!services.continuationQuote || !state?.snapshot || !phases.includes(state.phase)) return null;
try { return await services.continuationQuote(state, resolved); } catch { return { unavailable: true }; }
};
// The Task 3 verbs share one shape: validate, authorize, replay a recorded op, then check revision and phase in
// that order, so a lost response can be retried and a stale client always learns the revision moved.
const need = name => typeof services[name] === 'function' ? services[name] : refuse('request-services-unavailable');
const replay = (state, op, proposal_digest) => {
const old = journal.proposal(state.business, state.request, op);
if (old && old.proposal_digest !== proposal_digest) refuse('request-operation-conflict');
return old !== null;
};
const ended = state => { if (['cancelled', 'delivered', 'complete'].includes(state.phase)) refuse('request-phase-refused'); };
// The PM's one outstanding question. It is a journal event, not a decision: an answer is text and approves nothing.
async function question(identity, args) {
exact(args, ['request','revision','op','text'], ['options']);
if (!id(args.request) || !positive(args.revision) || !id(args.op) || !text(args.text, 2000) || ('options' in args && !optionList(args.options))) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'question');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (!QUESTION_PHASES.includes(state.phase) || state.wait?.kind === 'decision') refuse('request-phase-refused');
if (state.question && state.question.answer === null) refuse('request-question-open');
return journal.transaction(tx => {
const result = append(state, 'question.asked', { op: args.op, proposal_digest, id: 'q-' + proposal_digest.slice(0, 16), text: args.text,
options: structuredClone(args.options ?? []) }, identity, tx.appendRequest);
services.record?.(identity, 'request.question', result); return result;
});
});
}
async function answer(identity, args) {
exact(args, ['request','revision','question','op','text']);
if (!id(args.request) || !positive(args.revision) || !id(args.question) || !id(args.op) || !text(args.text, 8000)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'answer');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
ended(state);
if (!state.question || state.question.answer !== null) refuse('request-question-closed');
if (state.question.id !== args.question) refuse('request-question-mismatch');
// The event, the message to the PM and the journal row commit together, or none does.
return journal.transaction(tx => {
const event = need('recordAnswer')(identity, structuredClone(state), { question: args.question, op: args.op, text: args.text });
return append(state, 'question.answered', { op: args.op, proposal_digest, question: args.question, answer: event }, identity, tx.appendRequest);
});
});
}
// The owner's pick of one project the picker offers. The pick is evidence and a wait; the PM still proposes it and the
// owner still confirms the decision that proposal raises, so nothing is attached here.
async function attach(identity, args) {
exact(args, ['request','revision','project','op']);
if (!id(args.request) || !positive(args.revision) || !key(args.project) || !id(args.op)) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'attach');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'selection' || state.route !== 'existing' || state.wait?.kind !== 'setup' || !PICKER_CODES.includes(state.wait.code)) refuse('request-phase-refused');
active(state, args.project);
// The inventory is asynchronous; the transaction below rereads the journal and the check is repeated there.
const picked = await services.pickExisting?.(structuredClone(state), args.project, identity);
const rows = await projects(identity, { request: args.request }, false);
active(state, args.project);
if (!rows.some(p => p.project === args.project)) refuse('request-project-refused');
return journal.transaction(tx => {
const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found');
authorize(identity, current, 'attach');
active(current, args.project);
if (current.revision !== args.revision) refuse('request-revision-conflict');
need('recordAttach')(identity, structuredClone(current), { project: args.project, op: args.op });
return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project, ...(picked ? { mapping_revision: picked.mapping_revision } : {}) }, identity, tx.appendRequest);
});
});
}
// Withdraws the open selection decision, if any, and appends the cancellation. A project, mapping or receipt that
// already exists stays as evidence; nothing is deleted. A bootstrap in flight holds this request's queue, so a
// cancel waits for it to finish instead of racing it.
async function cancel(identity, args) {
exact(args, ['request','revision','op']);
if (!id(args.request) || !positive(args.revision) || !id(args.op)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'cancel');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
ended(state);
const decision = state.wait?.kind === 'decision' ? state.wait.subject : null;
return journal.transaction(tx => {
if (decision !== null) need('withdrawDecision')(identity, structuredClone(state), decision);
need('recordCancel')(identity, structuredClone(state), { op: args.op, decision });
return append(state, 'cancelled', { decision, op: args.op, proposal_digest }, identity, tx.appendRequest);
});
});
}
async function advance(identity, args) {
exact(args, ['request','revision','op']);
if (!id(args.request) || !id(args.op) || !Number.isSafeInteger(args.revision) || args.revision < 1) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'advance');
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase === 'delivery') { services.progress(state); refuse('request-delivery-required'); }
if (!state.snapshot || !services.progress) refuse('request-evidence-required');
const resolved = await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot });
// The post-advance coder/review continuation quotes the approved texts; resolve it here (Git
// reads) and hand it into the transaction-built message. planning→implementation and
// implementation→review dispatch those steps, and a review phase can loop back to
// implementation on a changes verdict, so every advance out of those three phases resolves.
// The pre-append state carries the same product, snapshot and task citations as the next one.
const quote = await quoteFor(state, { resolved, phases: ['planning', 'implementation', 'review'] });
return journal.transaction(tx => {
state = stateOf(state.business, state.request); authorize(identity, state, 'advance');
if (state.revision !== args.revision) refuse('request-revision-conflict');
const progress = services.progress(state);
const result = append(state, 'advanced', { op: args.op, proposal_digest, from: state.phase, ...progress }, identity, tx.appendRequest);
services.record?.(identity, 'request.advance', result);
continuations.resume(result, quote);
return result;
});
});
}
async function deliver(identity, args) {
exact(args, ['request','revision','op','task_ref','verdict','path','verification_record']);
exact(args.verification_record, ['path','commit']);
if (!id(args.request) || !id(args.op) || !id(args.verdict) || !text(args.task_ref, 160) || !text(args.path, 4096) ||
!text(args.verification_record.path, 256) || !/^[a-f0-9]{40}$/.test(args.verification_record.commit) || !Number.isSafeInteger(args.revision) || args.revision < 1) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'deliver');
const proposal = digest(args), old = journal.effects(state.business, state.request).filter(e => e.kind === 'delivery');
const committed = old.find(e => e.stage === 'committed');
if (committed) {
if (committed.op === args.op && committed.digest !== proposal) refuse('request-operation-conflict');
const d = committed.result;
if (d.task_ref !== args.task_ref || d.verdict !== args.verdict || d.artifact.path !== args.path || d.verification.record_path !== args.verification_record.path || d.verification.record_commit !== args.verification_record.commit) refuse('request-operation-conflict');
return d;
}
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'delivery') refuse('request-phase-refused');
const header = { version: 1, business: state.business, request: state.request, op: args.op, kind: 'delivery', digest: proposal };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
const evidence = services.deliveryEvidence(state, args);
const [verification, artifact] = await Promise.all([services.readVerification(state, args.verification_record), services.readReviewedArtifact(state, evidence.commit, args.path)]);
let record; try { record = JSON.parse(verification.content); } catch { refuse('request-verification-refused'); }
exact(record, ['version','task_ref','review_request','commit','results'], [], 'request-verification-refused');
if (verification.commit !== args.verification_record.commit || record.version !== 1 || record.task_ref !== args.task_ref || record.review_request !== evidence.review_request || record.commit !== evidence.commit || record.results !== evidence.results || !text(record.results)) refuse('request-verification-refused');
if (artifact.mime !== 'text/html') refuse('artifact-type-refused');
await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot });
const filename = basename(args.path);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) refuse('artifact-path-invalid');
return journal.transaction(tx => {
state = stateOf(state.business, state.request); authorize(identity, state, 'deliver');
if (state.revision !== args.revision || state.phase !== 'delivery') refuse('request-revision-conflict');
const again = services.deliveryEvidence(state, args);
if (digest(again) !== digest(evidence)) refuse('request-evidence-required');
const value = { version: 1, id: randomUUID(), business: state.business, request: state.request, project: state.project, snapshot: state.snapshot,
task_ref: args.task_ref, commit: evidence.commit, review_request: evidence.review_request, verdict: evidence.verdict,
artifact: { path: args.path, blob: artifact.blob, sha256: artifact.sha256, bytes: artifact.bytes.length, mime: artifact.mime },
verification: { record_path: args.verification_record.path, record_commit: args.verification_record.commit }, message: null };
const result = append(state, 'advanced', { op: args.op, proposal_digest: proposal, from: 'delivery', phase: 'complete', tasks: state.tasks, evidence: evidence.evidence }, identity, tx.appendRequest);
continuations.resume({ ...result, delivery: value.id, delivery_evidence: value });
const notice = journal.effects(state.business, state.request).find(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.phase === 'complete' && e.result.intent.revision === result.revision && e.result.intent.step === 'send_message');
if (!notice?.result?.message) refuse('request-notification-unavailable');
value.message = notice.result.message;
services.record?.(identity, 'request.deliver', result);
journal.finishEffect({ ...header, stage: 'committed', result: value, code: null });
return value;
});
});
}
async function artifactConsole(identity, args) {
exact(args, ['request','delivery']);
if (!id(args.request) || !id(args.delivery)) refuse('invalid-request');
const state = stateOf(identity.business, args.request); authorize(identity, state, 'artifactConsole');
const d = state.delivery_evidence;
if (!d || d.id !== args.delivery) refuse('request-not-found');
const artifact = await services.readReviewedArtifact(state, d.commit, d.artifact.path);
const current = stateOf(identity.business, args.request); authorize(identity, current, 'artifactConsole');
if (current.delivery !== d.id || artifact.sha256 !== d.artifact.sha256 || artifact.blob !== d.artifact.blob || artifact.bytes.length !== d.artifact.bytes) refuse('request-evidence-required');
return { mime: d.artifact.mime, filename: basename(d.artifact.path), base64: artifact.bytes.toString('base64'), sha256: artifact.sha256 };
}
return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, advance, deliver, artifactConsole, modeConsole,
reviewScope: (business, request, task, project, snapshot, coder, reviewer) => {
if (typeof request !== 'string') return false;
const row = journal.listRequests().find(r => r.business === business && r.request === request);
if (!row) return false;
const state = journal.readRequest(business, request);
const staff = state.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan.staff;
return ['planning', 'implementation', 'review', 'delivery'].includes(state.phase) && state.project === project &&
state.snapshot === snapshot && !!snapshot && staff?.coder === coder && staff?.reviewer === reviewer &&
(state.phase === 'planning' || state.tasks.includes(task));
},
hasWork: () => journal.listRequests().length > 0, launches: continuations.launches, launchResult: continuations.launchResult, manifestNeeded: continuations.manifestNeeded,
draftConsole: async (identity, args) => {
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft');
const material = state.candidate ? await services.readCandidate(state.candidate, state.business)
: { candidate: null, prd: null, requirements: [] };
const current = stateOf(state.business, state.request); authorize(identity, current, 'draft');
if (current.revision !== state.revision) refuse('request-revision-conflict');
const revision = current.project ? journal.snapshotEvents(current.business, current.project).at(-1)?.revision ?? 0 : 0;
return { candidate: material.candidate, prd: material.prd, requirements: material.requirements,
snapshot: { id: current.snapshot, revision } };
}, resume, catalog: services.catalog,
decisionClosed: (business, decision) => {
if (closing) return Promise.resolve();
for (const row of journal.listRequests()) if (row.business === business && journal.readRequest(business, row.request).wait?.subject === decision)
return resume(business, row.request);
},
close: async () => { closing = true; await recovery; await Promise.allSettled([...pending.values()]); await services.catalog?.drain(); },
recover: ({ limit = Infinity } = {}) => {
if (closing) return Promise.resolve([]);
// An overlapping event may concern any already-inspected request. Keep a
// full reinspection cycle pending, using the same per-batch limit.
if (recovery) { recoveryAgain = true; return recovery; }
recovery = (async () => {
const states = [];
let remaining = Math.min(journal.listRequests().length, limit);
do {
recoveryAgain = false;
const rows = journal.listRequests();
const count = Math.min(rows.length, limit, remaining), start = cursor % (rows.length || 1);
for (let n = 0; n < count; n++) {
if (closing) break;
const { business, request } = rows[(start + n) % rows.length];
states.push(await resume(business, request));
}
cursor = (start + count) % (rows.length || 1);
remaining = recoveryAgain ? journal.listRequests().length : Math.max(0, remaining - count);
if (count === 0) break;
} while (remaining > 0 && !closing);
return states;
})().finally(() => { recovery = null; });
return recovery;
} });
}
+394
View File
@@ -0,0 +1,394 @@
// Trusted host journal. No SQL, transition or effect interface is exposed to workers.
import { createHash } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
import { checkVars } from '../../business/src/vars.mjs';
export class RequestError extends Error {
constructor(code) { super(code); this.code = code; }
}
export const refuse = code => { throw new RequestError(code); };
export const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
export const text = (x, max = 32768) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
export const id = x => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
export const key = x => typeof x === 'string' && ID_PATTERN.test(x);
const hash = x => typeof x === 'string' && /^[a-f0-9]{64}$/.test(x);
const oid = x => typeof x === 'string' && /^[a-f0-9]{40}([a-f0-9]{24})?$/.test(x);
export const positive = x => Number.isSafeInteger(x) && x > 0;
export function exact(x, keys, optional = [], code = 'invalid-request') {
if (!object(x) || keys.some(k => !Object.hasOwn(x, k)) || Object.keys(x).some(k => !keys.includes(k) && !optional.includes(k))) refuse(code);
}
const canonical = x => Array.isArray(x) ? x.map(canonical) : object(x)
? Object.fromEntries(Object.keys(x).sort().map(k => [k, canonical(x[k])])) : x;
export const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(canonical(x))).digest('hex');
const equal = (a, b) => digest(a) === digest(b);
const nullableId = x => x === null || id(x);
// A question belongs to the PM's discovery, PRD review and planning work.
export const QUESTION_PHASES = ['discovery','prd-review','planning'];
export const PICKER_CODES = ['request-existing-project','request-attachment-selected'];
export const optionList = x => Array.isArray(x) && x.length <= 8 && x.every(o => object(o) && Object.keys(o).length === 2 && id(o.key) && text(o.text, 200)) &&
new Set(x.map(o => o.key)).size === x.length;
export function validatePlan(p) {
const code = 'invalid-request-plan', shape = (x, keys) => exact(x, keys, [], code);
shape(p, ['version','business','request','project','name','chat_project','mode','organization_revision','source','code_root','records_root','tracker','staff','intent','project_vars','existing']);
if (p.version !== 1 || !key(p.business) || !id(p.request) || !key(p.project) || !text(p.name, 200) || /[\r\n]/.test(p.name) ||
!nullableId(p.chat_project) || !['autonomous','guided'].includes(p.mode) || !text(p.organization_revision, 256) || typeof p.existing !== 'boolean') refuse(code);
shape(p.source, ['project','commit','remote','forks']);
if (!key(p.source.project) || !oid(p.source.commit) || (p.source.remote !== null && !parseGiteaUrl(p.source.remote, { repo: true }).url) || !Array.isArray(p.source.forks)) refuse(code);
const instances = new Set();
for (const f of p.source.forks) {
shape(f, ['instance','url','grant','state']);
if (!key(f.instance) || instances.has(f.instance) || !parseGiteaUrl(f.url, { repo: true }).url || !nullableId(f.grant) || !['verified','not-granted'].includes(f.state)) refuse(code);
instances.add(f.instance);
}
for (const path of [p.code_root, p.records_root]) if (!text(path, 4096) || !isAbsolute(path) || normalize(path) !== path) refuse(code);
if (p.code_root === p.records_root || p.code_root.startsWith(p.records_root + '/') || p.records_root.startsWith(p.code_root + '/')) refuse(code);
shape(p.tracker, ['root','project']);
if (!positive(p.tracker.root) || !positive(p.tracker.project)) refuse(code);
shape(p.staff, ['pm','technical_reviewer','coder','reviewer','chief']);
if (!Object.values(p.staff).every(key) || p.staff.pm === p.staff.technical_reviewer) refuse(code);
shape(p.intent, ['text','sha256','source_message','generated']);
if (!text(p.intent.text) || !hash(p.intent.sha256) || digest(p.intent.text) !== p.intent.sha256 || !id(p.intent.source_message) || typeof p.intent.generated !== 'boolean') refuse(code);
if (!object(p.project_vars) || Object.hasOwn(p.project_vars, 'tracker.project')) refuse(code);
try { checkVars(p.project_vars, 'project', 'request resource plan'); } catch { refuse(code); }
return p;
}
export function validateCandidate(c) {
const code = 'invalid-prd-candidate';
exact(c, ['version','request','project','prd_version','records_commit','prd_path','prd_blob','requirements','spec_commit','mode_revision'], [], code);
if (c.version !== 1 || !id(c.request) || !key(c.project) || typeof c.prd_version !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(c.prd_version) ||
!oid(c.records_commit) || c.prd_path !== `docs/prd-${c.prd_version}.md` || !oid(c.prd_blob) || !oid(c.spec_commit) || !positive(c.mode_revision) ||
!Array.isArray(c.requirements) || c.requirements.length < 1 || c.requirements.length > 64) refuse(code);
const seen = new Set();
for (const r of c.requirements) {
exact(r, ['id','title','path','blob'], [], code);
if (typeof r.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(r.id) || !text(r.title, 512) || !r.title.trim() ||
r.path !== `requirements/${r.id}.json` || !oid(r.blob) || seen.has(r.path)) refuse(code);
seen.add(r.path);
}
return c;
}
function initial(business, request, b) {
exact(b, ['source_message','thread','session','owner','pm','intent'], [], 'invalid-request-event');
if (!id(b.source_message) || !nullableId(b.thread) || !nullableId(b.session) || !id(b.owner) || !key(b.pm)) refuse('invalid-request-event');
exact(b.intent, ['text','sha256','source_message','generated'], [], 'invalid-request-event');
if (!text(b.intent.text) || digest(b.intent.text) !== b.intent.sha256 || b.intent.source_message !== b.source_message || b.intent.generated !== false) refuse('invalid-request-event');
return { version: 1, request, business, revision: 1, source_message: b.source_message, thread: b.thread, session: b.session,
owner: b.owner, pm: b.pm, phase: 'selection', project: null, chat_project: null, mode: null, mode_revision: 0,
plan_digest: null, spec_commit: null, snapshot: null, tasks: [], delivery: null, question: null, candidate: null, selection: null,
// The owner's chosen route survives every setup wait; a wait code never says which route was chosen.
route: null, wait: null, launch_refusal: null };
}
function reduce(state, e) {
const code = 'invalid-request-event', b = e.body;
if (e.kind === 'opened') { if (state || e.revision !== 1) refuse(code); return initial(e.business, e.request, b); }
if (!state || e.revision !== state.revision + 1 || state.phase === 'cancelled') refuse(code);
const s = structuredClone(state); s.revision = e.revision;
if (e.kind === 'setup.wait') {
exact(b, ['code','subject'], ['op','proposal_digest'], code);
if (!text(b.code, 128) || !id(b.subject) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code);
s.wait = { kind: 'setup', code: b.code, subject: b.subject, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'selection.proposed') {
exact(b, ['op','proposal_digest','decision','bindings','kind'], [], code);
if (s.phase !== 'selection' || s.wait?.kind === 'decision' || !id(b.op) || !hash(b.proposal_digest) || !id(b.decision) || !['mode','existing'].includes(b.kind) || !Array.isArray(b.bindings)) refuse(code);
const choices = b.kind === 'mode' ? ['autonomous','guided'] : ['confirm'];
if (b.bindings.length !== choices.length) refuse(code);
for (let i = 0; i < choices.length; i++) {
const binding = b.bindings[i]; exact(binding, ['plan','digest','decision','choice'], [], code); validatePlan(binding.plan);
if (binding.choice !== choices[i] || binding.decision !== b.decision || binding.digest !== digest(binding.plan) ||
binding.plan.business !== s.business || binding.plan.request !== s.request || binding.plan.staff.pm !== s.pm ||
binding.plan.intent.source_message !== s.source_message || binding.plan.existing !== (b.kind === 'existing') ||
(b.kind === 'mode' && binding.plan.mode !== binding.choice)) refuse(code);
}
s.selection = { decision: b.decision, bindings: structuredClone(b.bindings) };
s.wait = { kind: 'decision', code: b.kind === 'mode' ? 'request-mode' : 'request-existing-confirmation', subject: b.decision, resume_phase: 'selection', observed_revision: s.revision };
} else if (e.kind === 'selection.staged') {
exact(b, ['decision','choice'], [], code);
if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision || !['existing','back'].includes(b.choice) ||
(b.choice === 'existing') !== (s.wait.code === 'request-mode')) refuse(code);
s.selection = null; s.route = 'existing';
s.wait = { kind: 'setup', code: 'request-existing-project', subject: s.request, resume_phase: 'selection', observed_revision: s.revision };
} else if (e.kind === 'selection.confirmed') {
exact(b, ['decision','choice','digest'], [], code);
const binding = s.selection?.bindings.find(p => p.decision === b.decision && p.choice === b.choice && p.digest === b.digest);
if (s.wait?.kind !== 'decision' || !binding) refuse(code);
const p = binding.plan;
s.project = p.project; s.chat_project = p.chat_project; s.mode = p.mode; s.mode_revision = 1; s.plan_digest = binding.digest;
s.phase = p.existing ? 'discovery' : 'bootstrap';
s.wait = { kind: 'setup', code: p.existing ? 'request-attachment-unavailable' : 'request-bootstrap-unavailable', subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'bootstrap.stale') {
// The confirmed plan no longer matches the world (#800). Nothing was bound; the owner gets a fresh preview.
exact(b, ['digest','code'], [], code);
if (s.phase !== 'bootstrap' || s.plan_digest !== b.digest || b.code !== 'request-plan-stale') refuse(code);
s.project = null; s.chat_project = null; s.mode = null; s.mode_revision = 0; s.plan_digest = null; s.selection = null; s.phase = 'selection';
s.wait = { kind: 'setup', code: b.code, subject: s.request, resume_phase: 'selection', observed_revision: s.revision };
} else if (e.kind === 'bootstrap.completed') {
exact(b, ['digest','project','chat_project','spec_commit','organization_revision'], ['mode_revision','snapshot','candidate'], code);
const existing = s.selection?.bindings.find(p => p.digest === s.plan_digest)?.plan.existing;
if ((existing ? s.phase !== 'discovery' || s.attachment?.completed : s.phase !== 'bootstrap') ||
s.plan_digest !== b.digest || b.project !== s.project || !(existing ? nullableId(b.chat_project) : id(b.chat_project)) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code);
s.chat_project = b.chat_project; s.spec_commit = b.spec_commit; s.phase = 'discovery';
s.wait = { kind: 'setup', code: 'request-discovery-unavailable', subject: s.request, resume_phase: 'discovery', observed_revision: s.revision };
if (existing) {
if (!positive(b.mode_revision) || !nullableId(b.snapshot) || (b.snapshot === null) !== (b.candidate === null)) refuse(code);
s.mode_revision = b.mode_revision;
if (s.attachment) s.attachment.completed = true;
if (b.snapshot) {
validateCandidate(b.candidate);
if (b.candidate.project !== s.project || b.candidate.mode_revision !== s.mode_revision || b.candidate.spec_commit !== s.spec_commit) refuse(code);
s.candidate = structuredClone(b.candidate); s.snapshot = b.snapshot; s.phase = 'planning';
s.wait = { kind: 'setup', code: 'request-planning-unavailable', subject: s.request, resume_phase: 'planning', observed_revision: s.revision };
}
} else if (['mode_revision','snapshot','candidate'].some(k => k in b)) refuse(code);
} else if (e.kind === 'question.asked') {
// The PM's one outstanding question. The id is part of the body so a reader never recomputes it.
exact(b, ['op','proposal_digest','id','text','options'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !id(b.id) || !text(b.text, 2000) || !optionList(b.options)) refuse(code);
if (!QUESTION_PHASES.includes(s.phase) || s.wait?.kind === 'decision' || (s.question && s.question.answer === null)) refuse(code);
s.question = { id: b.id, text: b.text, options: structuredClone(b.options), answer: null };
s.wait = { kind: 'question', code: 'question', subject: b.id, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'question.answered') {
// `answer` is the owner's human.input event. appendInTransaction checks that event; text never approves anything.
exact(b, ['op','proposal_digest','question','answer'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !id(b.question) || !id(b.answer)) refuse(code);
if (!s.question || s.question.answer !== null || s.question.id !== b.question) refuse(code);
s.question.answer = b.answer;
if (s.wait?.kind === 'question' && s.wait.subject === b.question) s.wait = null;
} else if (e.kind === 'attach.selected') {
// The owner's pick of an existing project. It is a pick only: no mapping, effect or decision exists until the
// PM proposes exactly this project and the owner confirms the decision that proposal raises.
exact(b, ['op','proposal_digest','project'], ['mapping_revision'], code);
if (!id(b.op) || !hash(b.proposal_digest) || !key(b.project)) refuse(code);
if (s.phase !== 'selection' || s.route !== 'existing' || s.wait?.kind !== 'setup' || !PICKER_CODES.includes(s.wait.code)) refuse(code);
s.wait = { kind: 'setup', code: 'request-attachment-selected', subject: b.project, resume_phase: 'selection', observed_revision: s.revision };
if (b.mapping_revision !== undefined) {
if (!hash(b.mapping_revision)) refuse(code);
s.attachment = { project: b.project, mapping_revision: b.mapping_revision };
}
} else if (e.kind === 'prd.submitted') {
exact(b, ['candidate','decision','op','proposal_digest','authors','route'], [], code);
validateCandidate(b.candidate);
exact(b.route, ['instance','definition'], [], code);
if (!key(b.route.instance) || !key(b.route.definition)) refuse(code);
if (!['discovery','prd-review','planning'].includes(s.phase) || !id(b.decision) || !id(b.op) || !hash(b.proposal_digest) ||
b.candidate?.request !== s.request || b.candidate.project !== s.project || b.candidate.mode_revision !== s.mode_revision || !Array.isArray(b.authors) || !b.authors.length) refuse(code);
for (const a of b.authors) { exact(a, ['definition','instance','commit'], [], code); if (!key(a.definition) || !key(a.instance) || !oid(a.commit)) refuse(code); }
s.candidate = structuredClone(b.candidate); s.phase = 'prd-review'; s.snapshot = null; s.spec_commit = b.candidate.spec_commit;
s.wait = { kind: 'decision', code: 'prd-approval', subject: b.decision, resume_phase: 'prd-review', observed_revision: s.revision };
} else if (e.kind === 'prd.activated') {
exact(b, ['decision','snapshot'], [], code);
if (s.phase !== 'prd-review' || s.wait?.subject !== b.decision || !id(b.snapshot)) refuse(code);
s.snapshot = b.snapshot; s.phase = 'planning';
s.wait = { kind: 'setup', code: 'request-planning-unavailable', subject: s.request, resume_phase: 'planning', observed_revision: s.revision };
} else if (e.kind === 'prd.invalidated') {
exact(b, ['code'], [], code);
if (!text(b.code, 128)) refuse(code);
s.snapshot = null; s.candidate = null; s.phase = 'discovery';
s.wait = { kind: 'setup', code: b.code, subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'mode.changed') {
exact(b, ['mode','mode_revision','op','proposal_digest'], [], code);
if (!s.project || !['guided','autonomous'].includes(b.mode) || !positive(b.mode_revision) || !id(b.op) || !hash(b.proposal_digest)) refuse(code);
s.mode = b.mode; s.mode_revision = b.mode_revision;
s.candidate = null; s.snapshot = null; s.phase = 'discovery';
s.wait = { kind: 'setup', code: 'request-mode-changed', subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'advanced') {
exact(b, ['op','proposal_digest','from','phase','tasks','evidence'], [], code);
const next = { planning: ['implementation'], implementation: ['review'], review: ['delivery','implementation'], delivery: ['complete'] };
if (b.from !== s.phase || !next[s.phase]?.includes(b.phase) || !id(b.op) || !hash(b.proposal_digest) ||
!Array.isArray(b.tasks) || !b.tasks.length || !b.tasks.every(x => /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x)) ||
!Array.isArray(b.evidence) || !b.evidence.length || !b.evidence.every(id)) refuse(code);
s.tasks = [...b.tasks]; s.phase = b.phase; s.evidence = [...b.evidence]; s.wait = null;
} else if (e.kind === 'cancelled') {
// `decision` is the open selection decision the cancellation closes, or null when none is open. A project or
// mapping that already exists stays where it is; the journal never undoes an effect.
exact(b, ['decision'], ['op','proposal_digest'], code);
if (('op' in b) !== ('proposal_digest' in b) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code);
if (s.phase === 'delivered') refuse(code);
if (s.wait?.kind === 'decision' ? s.wait.subject !== b.decision : b.decision !== null) refuse(code);
s.phase = 'cancelled'; s.wait = null; s.selection = null;
} else refuse(code);
return s;
}
export function createRequestStore(store) {
let composing = false;
const compose = fn => {
const before = composing; composing = true;
try { return fn(); } finally { composing = before; }
};
const atomic = fn => composing ? fn() : store.transaction(() => compose(fn));
const validateIntent = (event, origin) => {
if (event.kind === 'selection.proposed' && (!Array.isArray(event.body.bindings) ||
event.body.bindings.some(b => !object(b?.plan?.intent) || !equal(b.plan.intent, origin?.intent)))) refuse('invalid-request-event');
};
const readRequest = (business, request) => {
if (!key(business) || !id(request)) refuse('invalid-request');
const events = store.all('SELECT * FROM request_events WHERE business=? AND request=? ORDER BY revision', business, request)
.map(e => ({ ...e, body: JSON.parse(e.body) }));
const state = events.reduce((s, e) => { validateIntent(e, events[0].body); return reduce(s, e); }, null);
if (!state) return state;
const launches = store.all("SELECT result FROM request_effects WHERE business=? AND request=? AND kind='continuation' AND stage='committed' AND json_extract(result,'$.type')='launch' ORDER BY seq", business, request).map(r => JSON.parse(r.result));
const active = new Set(store.all("SELECT json_extract(result,'$.intent.key') key FROM request_effects WHERE business=? AND request=? AND kind='continuation' AND stage='uncertain' AND json_extract(result,'$.intent.revision')=?", business, request, state.revision).map(r => r.key));
const latest = new Map();
for (const launch of launches) if (active.has(launch.key)) latest.set(launch.instance, launch);
const last = launches.findLast(launch => launch.code && latest.get(launch.instance) === launch);
const delivery = store.get("SELECT result FROM request_effects WHERE business=? AND request=? AND kind='delivery' AND stage='committed' ORDER BY seq LIMIT 1", business, request);
if (delivery && state.phase === 'complete') { const value = JSON.parse(delivery.result); state.delivery = value.id; state.delivery_evidence = value; }
state.launch_refusal = last ? { instance: last.instance, code: last.code, key: last.key } : null;
return state;
};
// appendInTransaction is only for composing the decision and journal atomically on the host.
const appendInTransaction = e => {
exact(e, ['business','request','expectedRevision','kind','body','actor']);
exact(e.actor, ['role','run']);
if (!key(e.business) || !id(e.request) || !Number.isSafeInteger(e.expectedRevision) || e.expectedRevision < 0 ||
!((e.actor.role === null && e.actor.run === null) || (key(e.actor.role) && id(e.actor.run)))) refuse('invalid-request-event');
const state = readRequest(e.business, e.request);
if ((state?.revision ?? 0) !== e.expectedRevision) refuse('request-revision-conflict');
const next = reduce(state, { ...e, revision: e.expectedRevision + 1 });
if (e.kind === 'opened') {
const source = store.get(`SELECT m.id,m.body,mt.thread,mt.session FROM events e JOIN messages m ON m.id=e.subject
AND m.id=json_extract(e.body,'$.message') AND m.business=e.business
LEFT JOIN message_threads mt ON mt.message=m.id
WHERE e.business=? AND e.id=? AND e.kind='human.input' AND e.actor_role IS NULL AND e.actor_run IS NULL
AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human'`, e.business, e.request);
if (!source || source.id !== e.body.source_message || source.body !== e.body.intent?.text ||
(source.thread ?? null) !== e.body.thread || (source.session ?? null) !== e.body.session) refuse('request-origin-refused');
}
if (e.kind === 'selection.proposed') {
const origin = JSON.parse(store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='opened'", e.business, e.request).body);
validateIntent(e, origin);
const d = store.get('SELECT * FROM decisions WHERE business=? AND id=?', e.business, e.body.decision);
if (!d || d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null ||
d.raised_by_role !== state.pm || store.get("SELECT 1 FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired')", d.id)) refuse('invalid-request-event');
}
if (e.kind === 'prd.submitted') {
const d = store.get('SELECT * FROM decisions WHERE business=? AND id=?', e.business, e.body.decision);
if (!d || d.action !== 'prd.approve' || d.project !== state.project || d.raised_by_role !== state.pm || d.raised_by_role !== e.actor.role ||
d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null || d.route_to !== e.body.route.instance) refuse('invalid-request-event');
}
if (e.kind === 'advanced') {
if (e.actor.role !== state.pm || e.body.evidence.some(eid => !store.get('SELECT 1 FROM events WHERE business=? AND id=?', e.business, eid))) refuse('invalid-request-event');
}
if (e.kind === 'prd.activated') {
const row = store.get('SELECT body FROM request_snapshots WHERE id=? AND business=? AND project=?', e.body.snapshot, e.business, state.project);
if (!row || JSON.parse(row.body).approval.decision !== e.body.decision) refuse('invalid-request-event');
}
if (e.kind === 'selection.confirmed' && !store.get('SELECT 1 FROM request_project_events WHERE business=? AND project=?', e.business, next.project))
store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,1,'attached',?)",
new Date().toISOString(), e.business, next.project, JSON.stringify({ mode: next.mode, request: e.request }));
// An answer follows only from the owner's own console human.input for that question and op, so the journal
// cannot record text the owner never sent or the origin instruction as an answer.
if (e.kind === 'question.answered') {
const a = store.get("SELECT body FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL", e.business, e.body.answer);
const x = a && JSON.parse(a.body);
if (!x || x.kind !== 'answer' || x.via !== 'console' || x.request !== e.request || x.question !== e.body.question || x.op !== e.body.op) refuse('invalid-request-event');
}
// A bootstrap outcome follows only from the receipts it names, so the journal cannot claim a project
// that was not activated, or retreat from one whose mapping already landed.
if (e.kind === 'bootstrap.completed' || e.kind === 'bootstrap.stale') {
const op = step => store.get("SELECT result FROM request_effects WHERE business=? AND request=? AND op=? AND kind='bootstrap' AND stage='committed'",
e.business, e.request, `bootstrap:${e.body.digest}:${step}`);
if (e.kind === 'bootstrap.completed') {
const existing = state.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan.existing;
const done = op(existing ? 'attach' : 'activate');
if (!done || JSON.parse(done.result).project !== e.body.project) refuse('invalid-request-event');
if (existing) {
const { project, ...outcome } = JSON.parse(done.result);
if (!equal(outcome, Object.fromEntries(['spec_commit','mode_revision','snapshot','candidate'].map(k => [k, e.body[k]])))) refuse('invalid-request-event');
if (e.body.snapshot) {
const snap = store.get('SELECT body FROM request_snapshots WHERE business=? AND project=? AND id=?', e.business, project, e.body.snapshot);
if (!snap || JSON.parse(snap.body).records_commit !== e.body.candidate.records_commit) refuse('invalid-request-event');
}
}
} else if (op('mapping')) refuse('invalid-request-event');
}
store.run('INSERT INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body) VALUES(?,?,?,?,?,?,?,?)',
e.business, e.request, next.revision, e.kind, e.actor.role, e.actor.run, new Date().toISOString(), JSON.stringify(e.body));
return next;
};
function receipt(r, prepare) {
exact(r, ['version','business','request','op','kind','digest','stage','result','code'], [], 'invalid-request-effect');
if (r.version !== 1 || !key(r.business) || !id(r.request) || !id(r.op) || !hash(r.digest) ||
!['bootstrap','prd-submit','prd-activate','task-create','continuation','delivery'].includes(r.kind) ||
!(prepare ? r.stage === 'prepared' && r.result === null && r.code === null : ['committed','uncertain','refused'].includes(r.stage)) ||
!(r.result === null || object(r.result)) || !(r.code === null || text(r.code, 128))) refuse('invalid-request-effect');
return atomic(() => {
if (!readRequest(r.business, r.request)) refuse('request-not-found');
const rows = store.all('SELECT version,business,request,op,kind,digest,stage,result,code FROM request_effects WHERE business=? AND request=? AND op=? ORDER BY seq', r.business, r.request, r.op)
.map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) }));
if (rows.some(x => x.digest !== r.digest || x.kind !== r.kind)) refuse('request-effect-conflict');
// A retry must see committed/uncertain/refused evidence instead of executing again.
if (prepare && rows.length) return structuredClone(rows.at(-1));
const old = rows.find(x => x.stage === r.stage);
if (old) { if (!equal(old, r)) refuse('request-effect-conflict'); return structuredClone(r); }
if (!prepare && !rows.some(x => x.stage === 'prepared')) refuse('request-effect-unprepared');
if (rows.some(x => ['committed','refused'].includes(x.stage))) refuse('request-effect-conflict');
store.run('INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage,result,code) VALUES(?,?,?,?,?,?,?,?,?,?)',
new Date().toISOString(), r.version, r.business, r.request, r.op, r.kind, r.digest, r.stage, r.result === null ? null : JSON.stringify(r.result), r.code);
return structuredClone(r);
});
}
const projectState = (business, project) => {
const row = store.get('SELECT * FROM request_project_events WHERE business=? AND project=? ORDER BY revision DESC LIMIT 1', business, project);
if (row) return { revision: row.revision, ...JSON.parse(row.body) };
// Task 1/2 databases predate mode writers. Their confirmed selection is the durable initial mode.
for (const r of store.all('SELECT DISTINCT request FROM request_events WHERE business=?', business)) {
const state = readRequest(business, r.request);
if (state.project === project && state.mode_revision > 0) return { revision: state.mode_revision, mode: state.mode, request: state.request };
}
return null;
};
return Object.freeze({ compose, readRequest, appendRequest: e => atomic(() => appendInTransaction(e)),
prepareEffect: r => receipt(r, true), finishEffect: r => receipt(r, false),
transaction: fn => atomic(() => fn({ appendRequest: appendInTransaction, readRequest })),
// Receipts read back, parsed. Hosts use these to resume a bootstrap and to rebuild readiness.
effects: (business, request) => {
if (!key(business) || !id(request)) refuse('invalid-request');
return store.all('SELECT version,business,request,op,kind,digest,stage,result,code FROM request_effects WHERE business=? AND request=? ORDER BY seq', business, request)
.map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) }));
},
committed: (business, kind) => {
if (!key(business)) refuse('invalid-request');
return store.all("SELECT request,op,digest,result FROM request_effects WHERE business=? AND kind=? AND stage='committed' ORDER BY seq", business, kind)
.map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) }));
},
listRequests: () => store.all('SELECT DISTINCT business,request FROM request_events ORDER BY business,request'),
stateForRequest: request => {
const row = store.get('SELECT business FROM request_events WHERE request=? LIMIT 1', request);
return row ? readRequest(row.business, request) : null;
},
submission: (business, request, decision) => {
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='prd.submitted' AND json_extract(body,'$.decision')=?", business, request, decision);
return row ? JSON.parse(row.body) : null;
},
projectState,
intent: (business, request) => {
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='opened'", business, request);
return row ? JSON.parse(row.body).intent : null;
},
changeMode: ({ business, project, revision, mode, op, request }) => atomic(() => {
const last = projectState(business, project);
if ((last?.revision ?? 0) !== revision) refuse('request-mode-revision-conflict');
store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,?,'mode.changed',?)",
new Date().toISOString(), business, project, revision + 1, JSON.stringify({ mode, op, request }));
return revision + 1;
}),
snapshotRequest: snapshot => store.get("SELECT request FROM request_effects WHERE kind='prd-activate' AND stage='committed' AND json_extract(result,'$.snapshot')=?", snapshot)?.request,
snapshot: snapshot => {
const row = store.get('SELECT body FROM request_snapshots WHERE id=?', snapshot);
return row ? JSON.parse(row.body) : null;
},
snapshotEvents: (business, project) => store.all('SELECT * FROM request_snapshot_events WHERE business=? AND project=? ORDER BY revision', business, project),
publishSnapshot: snapshot => store.run('INSERT INTO request_snapshots(id,at,business,project,body) VALUES(?,?,?,?,?)',
snapshot.id, new Date().toISOString(), snapshot.business, snapshot.project, JSON.stringify(snapshot)),
snapshotEvent: ({ business, project, snapshot, kind, cause }) => {
const revision = 1 + (store.get('SELECT max(revision) n FROM request_snapshot_events WHERE business=? AND project=?', business, project).n ?? 0);
store.run('INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,?,?,?,?,?,?)',
new Date().toISOString(), business, project, revision, snapshot, kind, cause);
return { snapshot, revision, kind, cause };
},
proposal: (business, request, op) => {
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND json_extract(body,'$.op')=? ORDER BY seq DESC LIMIT 1", business, request, op);
return row ? JSON.parse(row.body) : null;
} });
}
+63
View File
@@ -0,0 +1,63 @@
// Resolve only a confirmed request's assigned coder and a host-recorded checkout.
import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { isAbsolute, join } from 'node:path';
import { workspaceDir } from '../../seat/src/session.mjs';
import { workspaceFiles } from '../../seat/src/workspace-files.mjs';
const id = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(s) && s !== '..';
const unavailable = () => ({ available: false, entries: [], truncated: false, contentAvailable: false, observedAt: new Date().toISOString() });
const flags = constants.O_RDONLY | constants.O_NOFOLLOW;
function proofFile(path) {
// Evidence directories are host-owned, but do not let a substituted ancestor redirect a read.
const fds = [];
try {
let dir = openSync('/', flags | constants.O_DIRECTORY); fds.push(dir);
const parts = path.split('/').filter(Boolean), file = parts.pop();
if (!file || parts.some(p => p === '.' || p === '..') || file === '.' || file === '..') throw new Error('unsafe proof');
for (const part of parts) { dir = openSync(`/proc/self/fd/${dir}/${part}`, flags | constants.O_DIRECTORY); fds.push(dir); }
const fd = openSync(`/proc/self/fd/${dir}/${file}`, flags); fds.push(fd);
const st = fstatSync(fd); if (!st.isFile() || st.size > 1024 * 1024 || st.nlink !== 1) throw new Error('unsafe proof');
return readFileSync(fd, 'utf8');
} finally { for (const fd of fds.reverse()) closeSync(fd); }
}
export function requestWorkspace({ dataRoot, state, events }) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || dataRoot.split('/').includes('..')) return unavailable();
if (!state || !id(state.business) || !id(state.request) || !id(state.project) || !id(state.pm) || !state.snapshot || !Array.isArray(state.tasks) || !state.tasks.length) return unavailable();
const binding = state.selection?.bindings?.filter(b => b.digest === state.plan_digest) ?? [];
if (binding.length !== 1 || binding[0].plan.business !== state.business || binding[0].plan.request !== state.request || binding[0].plan.project !== state.project || binding[0].plan.staff?.pm !== state.pm || !id(binding[0].plan.staff?.coder)) return unavailable();
const coder = binding[0].plan.staff.coder;
if (!Array.isArray(events) || events.length > 10000) return unavailable();
const rows = events.map(e => { try { return { ...e, body: typeof e.body === 'string' ? JSON.parse(e.body) : e.body }; } catch { return null; } }).filter(Boolean);
for (const task of state.tasks) {
if (typeof task !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,159}$/.test(task)) return unavailable();
const created = rows.find(e => e.kind === 'task.created' && e.actor_role === state.pm && e.subject === task && e.body?.request === state.request && e.body?.project_key === state.project && e.body?.requirement_snapshot === state.snapshot);
const assigned = rows.findLast(e => e.kind === 'task.assigned' && e.subject === task);
if (!created || assigned?.actor_role !== state.pm || assigned.body?.role !== coder) return unavailable();
}
const root = workspaceDir(dataRoot, state.business, coder), checkout = join(root, state.project);
if (/[\n\r;,]/.test(checkout)) return unavailable();
const launches = rows.filter(e => e.kind === 'session.launched' && e.actor_role === coder);
if (launches.some(e => e.body?.thread)) return unavailable();
let proven = false;
for (const e of launches.slice(-32)) {
if (!id(e.subject)) continue;
try {
const bundle = join(dataRoot, 'launches', state.business, e.subject, 'bundle');
const manifest = JSON.parse(proofFile(join(bundle, 'manifest.json')));
if (manifest.business !== state.business || manifest.instance !== coder || manifest.run !== e.subject || !Array.isArray(manifest.projects) || !manifest.projects.includes(state.project)) continue;
// The manifest projects field includes unsafe, kept entries. The host prompt records that
// outcome; do not treat a kept entry as a verified checkout.
const prompt = proofFile(join(bundle, 'prompt.md'));
if (createHash('sha256').update(prompt).digest('hex') !== manifest.files?.['prompt.md']) continue;
const lines = prompt.split('\n').filter(x => x.startsWith('- Project clones: '));
if (lines.length !== 1) continue;
const items = lines[0].slice('- Project clones: '.length).split('; ');
const item = items.find(x => x.startsWith(`${state.project} at ${checkout}, `));
if (item && (item.startsWith(`${state.project} at ${checkout}, on branch `) || item.startsWith(`${state.project} at ${checkout}, on no branch `))) proven = true;
} catch { /* missing or unsafe evidence never selects a workspace */ }
}
if (!proven) return unavailable();
try { return workspaceFiles(checkout); } catch { return unavailable(); }
}
+110
View File
@@ -0,0 +1,110 @@
import { requestArchived } from './broker.mjs';
// Host-only admission. The journal's readCandidate/authority/approval adapters read current host state.
import { exact, id, key, text, digest, refuse } from './request-store.mjs';
export function createRequirementCatalog({ journal, readSpec, builtinBinding, projectArchived, threadProject }) {
const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request,
expectedRevision: state.revision, kind, body, actor: { role: null, run: null } });
const eventFor = snap => journal.snapshotEvents(snap.business, snap.project).filter(e => e.snapshot === snap.id).at(-1);
function invalidate(business, project, code, inTransaction = false) {
return (inTransaction ? journal.compose : journal.transaction)(() => {
const latest = new Map(journal.snapshotEvents(business, project).map(e => [e.snapshot, e]));
for (const e of latest.values()) if (e.kind === 'activated') journal.snapshotEvent({ business, project, snapshot: e.snapshot, kind: 'revoked', cause: code });
for (const row of journal.listRequests()) {
if (row.business !== business) continue;
const state = journal.readRequest(business, row.request);
if (state.project === project && !['complete','delivered','cancelled'].includes(state.phase) && (state.candidate || state.snapshot)) append(state, 'prd.invalidated', { code });
}
});
}
async function resolve(args) {
exact(args, ['business','project'], ['snapshot']);
if (!key(args.business) || !key(args.project) || (args.snapshot !== undefined && !id(args.snapshot))) refuse('invalid-request');
const { business, project } = args;
const builtin = await builtinBinding?.({ business, project });
if (args.snapshot && builtin?.id === args.snapshot) return structuredClone(builtin);
const events = journal.snapshotEvents(business, project);
const snapshot = args.snapshot ?? [...events].reverse().find(e => e.kind === 'activated')?.snapshot;
if (!snapshot) {
if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin;
refuse('requirement-snapshot-required');
}
const snap = journal.snapshot(snapshot);
if (!snap || snap.business !== business || snap.project !== project) refuse('requirement-snapshot-refused');
if (eventFor(snap)?.kind !== 'activated') refuse('requirement-snapshot-revoked');
const spec = await readSpec({ business, project });
const mode = journal.projectState(business, project);
if (!spec.confirmed || spec.commit !== snap.spec_commit || mode?.revision !== snap.mode_revision) {
invalidate(business, project, 'requirement-snapshot-revoked'); refuse('requirement-snapshot-revoked');
}
const state = journal.stateForRequest(journal.snapshotRequest(snap.id));
const binding = state && journal.submission(business, state.request, snap.approval.decision);
const authority = journal.authority(state, binding);
if (digest(journal.approval(state, binding)) !== digest(snap.approval)) refuse('prd-authority-refused');
// Reread immutable records, including selected-path equality at HEAD, before admitting new work.
const material = await journal.readCandidate(binding.candidate, business);
if (digest(material.candidate) !== digest(binding.candidate) || digest(material.authors) !== digest(snap.authors) ||
digest(material.candidate.requirements.map((r, i) => ({ ...r, text: material.requirements[i].text }))) !== digest(snap.requirements)) refuse('prd-candidate-stale');
if (digest(journal.authority(journal.stateForRequest(state.request), binding)) !== digest(authority) || eventFor(snap)?.kind !== 'activated' || journal.projectState(business, project)?.revision !== snap.mode_revision) refuse('requirement-snapshot-revoked');
return structuredClone(snap);
}
async function activateOnce(args) {
exact(args, ['request','decision']); if (!id(args.request) || !id(args.decision)) refuse('invalid-request');
const state = journal.stateForRequest(args.request) ?? refuse('request-not-found');
const binding = journal.submission(state.business, state.request, args.decision) ?? refuse('prd-candidate-required');
const old = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (old) return structuredClone(journal.snapshot(old.result.snapshot));
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
if (state.phase !== 'prd-review' || state.wait?.subject !== args.decision || digest(state.candidate) !== digest(binding.candidate)) refuse('prd-admission-revoked');
const authority = journal.authority(state, binding), approval = journal.approval(state, binding);
const material = await journal.readCandidate(binding.candidate, state.business);
if (digest(material.candidate) !== digest(binding.candidate) || digest(material.authors) !== digest(binding.authors)) refuse('prd-candidate-stale');
const spec = await readSpec({ business: state.business, project: state.project });
if (!spec.confirmed || spec.commit !== binding.candidate.spec_commit) refuse('stale-spec');
return journal.transaction(() => {
const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (prior) return structuredClone(journal.snapshot(prior.result.snapshot));
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
const current = journal.readRequest(state.business, state.request);
if (current.revision !== state.revision || current.wait?.subject !== args.decision || journal.projectState(state.business, state.project)?.revision !== binding.candidate.mode_revision) refuse('prd-admission-revoked');
if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked');
const c = binding.candidate, snapshot = { version: 1, id: `prd-${digest({ business: state.business, candidate: c, decision: args.decision })}`,
business: state.business, project: state.project, prd_version: c.prd_version, records_commit: c.records_commit, prd_path: c.prd_path,
prd_blob: c.prd_blob, spec_commit: c.spec_commit, requirements: c.requirements.map((r, i) => ({ ...r, text: material.requirements[i].text })),
authors: material.authors, approval, mode_revision: c.mode_revision, origin: 'product' };
const header = { version: 1, business: state.business, request: state.request, op: `prd-activate:${args.decision}`, kind: 'prd-activate', digest: digest(c) };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
journal.publishSnapshot(snapshot);
journal.snapshotEvent({ business: state.business, project: state.project, snapshot: snapshot.id, kind: 'activated', cause: args.decision });
append(current, 'prd.activated', { decision: args.decision, snapshot: snapshot.id });
journal.finishEffect({ ...header, stage: 'committed', result: { snapshot: snapshot.id, decision: args.decision }, code: null });
return snapshot;
});
}
function revoke(identity, args) {
exact(args, ['project','snapshot','revision','reason','op']);
if (!key(args.project) || !id(args.snapshot) || !Number.isSafeInteger(args.revision) || args.revision < 1 || !text(args.reason, 1024) || !id(args.op)) refuse('invalid-request');
return journal.transaction(() => {
const snap = journal.snapshot(args.snapshot);
if (!snap || snap.business !== identity.business || snap.project !== args.project) refuse('requirement-snapshot-refused');
const cause = JSON.stringify({ op: args.op, reason: args.reason, by: identity.human, digest: digest(args) });
const events = journal.snapshotEvents(identity.business, args.project), old = events.find(e => e.kind === 'revoked' && e.cause === cause);
if (old) return { snapshot: old.snapshot, revision: old.revision, kind: old.kind, cause: old.cause };
if (events.some(e => { try { return JSON.parse(e.cause).op === args.op; } catch { return false; } })) refuse('request-operation-conflict');
if ((events.at(-1)?.revision ?? 0) !== args.revision) refuse('request-revision-conflict');
const result = journal.snapshotEvent({ business: identity.business, project: args.project, snapshot: snap.id, kind: 'revoked', cause });
for (const row of journal.listRequests()) {
const state = journal.readRequest(row.business, row.request);
if (row.business === identity.business && state.project === args.project && !['complete','delivered','cancelled'].includes(state.phase) && state.snapshot === snap.id) append(state, 'prd.invalidated', { code: 'requirement-snapshot-revoked' });
}
return result;
});
}
const pending = new Map();
function activate(args) {
const operation = `${args?.request}/${args?.decision}`;
if (pending.has(operation)) return pending.get(operation);
const job = activateOnce(args).finally(() => { if (pending.get(operation) === job) pending.delete(operation); });
pending.set(operation, job); return job;
}
return Object.freeze({ resolve, activate, invalidate, revoke, drain: () => Promise.allSettled([...pending.values()]) });
}
+174 -5
View File
@@ -1,10 +1,20 @@
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import { createRequestStore } from './request-store.mjs';
import { createRequestLifecycle } from './request-lifecycle.mjs';
import { requestWorkspace } from './request-workspace.mjs';
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
import { executeBootstrap } from './request-bootstrap.mjs';
import { hostPreparation } from './request-host.mjs';
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Broker, projectArchived, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
@@ -20,6 +30,7 @@ import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -254,6 +265,13 @@ export async function startBroker({
consoleOwners = [],
// P1 validated sidecar from the trusted host; never a socket request setting.
proxy = null,
// Host-only preparation/admission capabilities. The host builds its own from the organization writer and `trackers`
// (request-host.mjs); anything given here replaces the host's, and a capability nothing supplies leaves a setup wait.
requestServices = {},
// The boot config's plain-data tracker settings, { business: { project, ... } }. The tasks adapter reads its own copy;
// the host's new-project preparation reads each business's tracker root board from this one.
trackers = null,
builtinBinding = null,
}) {
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
@@ -269,8 +287,29 @@ export async function startBroker({
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null;
let modelKeys = null, modelTimer = null, modelSweep = null;
const modelRuns = new Map();
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
try {
// Lifecycle Task 2 (#797): a product whose mapping is published but not yet activated is not part of
// this process. A managed product is part of it only with both a committed activate receipt in the
// request journal and a complete marker on disk (#802: a receipt alone, with the marker gone or never
// published, is not enough); every other managed entry waits for the bootstrap executor's
// activateManagedProject, so the broker, the Credentials roots and the boot copy never see a
// half-built product. `businesses` becomes this process's own copy.
store = new Store(dataRoot);
const requestJournal = createRequestStore(store);
const activatedProjects = business => new Set(requestJournal.committed(business, 'bootstrap')
.filter(r => r.op.endsWith(':activate') && typeof r.result?.project === 'string').map(r => r.result.project));
const pendingProject = (business, project, entry, activated) => {
if (entry?.kind === 'library') return false;
const state = managedState({ dataRoot, business, project, entry });
return state !== 'unmanaged' && !(state === 'complete' && activated.has(project));
};
const withoutPending = (business, b) => {
const activated = activatedProjects(business);
const projects = Object.fromEntries(Object.entries(b.projects ?? {}).filter(([project, entry]) => !pendingProject(business, project, entry, activated)));
return Object.keys(projects).length === Object.keys(b.projects ?? {}).length ? b : { ...b, projects };
};
businesses = Object.fromEntries(Object.entries(businesses).map(([id, b]) => [id, withoutPending(id, b)]));
const proxyReceipt = entry => process.stderr.write(`model-proxy: ${JSON.stringify(entry)}\n`);
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -285,7 +324,7 @@ export async function startBroker({
];
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
store = new Store(dataRoot);
let requestLifecycle;
// Store's writer lock admits one bus per data root before it holds the management client.
if (proxy) {
if (!organization) throw new BusError('not-configured');
@@ -474,7 +513,7 @@ export async function startBroker({
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);
const next = withoutPending(id, busBusiness(b, resolved));
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
@@ -486,7 +525,100 @@ export async function startBroker({
return false;
}
}
// Lifecycle Task 2 (#797): the only way a project joins a running bus. The bootstrap executor calls it
// synchronously after every earlier step passed, the complete marker is published and the activate receipt commits. It admits exactly the
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
if (Object.hasOwn(businesses[id].projects ?? {}, project)) return; // already admitted: activation is idempotent
const refuse = code => { throw new BusError(code); };
writer.hold(id, ({ revision }) => {
const found = writer.findAddition(id, receipt);
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
if (revision !== found.after) refuse('bootstrap-revision-stale');
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 full = busBusiness(b, resolved);
const entry = full.projects?.[project];
const here = managedPaths({ dataRoot, business: id, project });
if (!entry || entry.kind === 'library' || entry.root !== here.root || entry.repo !== here.repo
|| Object.keys(entry).some(k => !['root', 'repo', 'remote'].includes(k))) refuse('bootstrap-mapping-mismatch');
if (managedState({ dataRoot, business: id, project, entry }) !== 'complete') refuse('bootstrap-marker-incomplete');
if (!activatedProjects(id).has(project)) refuse('bootstrap-receipt-mismatch');
const next = withoutPending(id, full);
const { [project]: _added, ...before } = next.projects;
if (canonicalJson(before) !== known.get(id)) refuse('bootstrap-mapping-mismatch');
const expected = { ...booted.get(id), roots: canonicalJson([...JSON.parse(booted.get(id).roots), entry.root].sort()) };
if (!sameFixed(expected, next)) refuse('bootstrap-restart-required');
broker.reloadBusiness(id, next);
protectedRoots.push(entry.root);
businesses[id] = next;
booted.set(id, expected);
known.set(id, canonicalJson(next.projects));
});
}
// Host IPC request.admission: may a project's code checkout be cloned and its tools used. A project the
// broker does not hold is never ready, and a declared one that is not yet held says it is pending. A managed
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
function admission({ business: id, project, projects, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
if (thread != null) {
const row = broker.threadsState(id).find(r => r.thread === thread && r.session === session);
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
}
return { ready: true, revision: null, code: null };
}
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown');
if (projectArchived(store, id, project)) return no('project-archived');
let revision = null, declared = false;
try {
if (writer) {
const now = writer.read(id);
revision = now.revision; declared = Object.hasOwn(now.document.projects ?? {}, project);
}
} catch { return no('organization-unreadable'); }
if (Object.hasOwn(businesses[id].projects ?? {}, project)) {
try { if (pendingProject(id, project, businesses[id].projects[project], activatedProjects(id))) return no('project-pending'); }
catch { return no('project-pending'); }
return { ready: true, revision, code: null };
}
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
broker = new Broker({
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
const readCandidate = (candidate, business) => {
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
return readPrdMaterial({ business: services.prdBusiness(business), dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
requirement_ids: candidate.requirement_ids ?? candidate.requirements.map(r => r.id) });
};
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived, threadProject: services.threadProject });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }) }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, threadProject: services.threadProject,
chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } }));
},
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
// FED R4 (#1801, design 3.7 trigger 1): a request stats the file and reads it only when this
@@ -550,6 +682,22 @@ 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?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busBusiness(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
writer.setProjectRemote(id, args, actor);
const next = structuredClone(live);
next.projects[args.project].remote = args.remote;
broker.reloadBusiness(id, next);
businesses[id] = next;
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
@@ -740,7 +888,7 @@ export async function startBroker({
refusing(cap, () => broker.authorize(cap, 'role.launch', { target: instance }));
// Row 93 (#1587): the host's git socket. A push is checked before the host takes the bundle
// and recorded once the ref moved; a reviewer's fetch is checked against its review.requested.
const checkPush = (cap, task_ref) => refusing(cap, () => broker.checkPush(cap, task_ref), task_ref);
const checkPush = (cap, task_ref, project) => refusing(cap, () => broker.checkPush(cap, task_ref, project), task_ref);
const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref);
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
@@ -877,8 +1025,26 @@ export async function startBroker({
usageTimer = setInterval(usageTick, 1000);
usageTimer.unref();
usageTick(); // Optional telemetry never blocks the native ready answer.
// Startup recovery waits while processing is halted or invalid, like the periodic pass below;
// processing.resume schedules it again.
const startState = processing.read();
if (!startState.halted && !startState.invalid) await requestLifecycle.recover();
requestTimer = setInterval(() => {
const state = processing.read();
if (state.halted || state.invalid) return;
requestLifecycle.recover({ limit: 64 }).catch(e => process.stderr.write(`request: ${JSON.stringify({ event: 'reconcile-refused', code: e.code ?? 'request-failed' })}\n`));
}, 30000);
requestTimer.unref();
return {
broker,
requestLifecycle,
requirementCatalog: requestLifecycle.catalog,
admission,
requestLaunches: business => requestLifecycle.launches(business),
requestLaunchResult: record => requestLifecycle.launchResult(record),
requestManifest: record => requestLifecycle.manifestNeeded(record),
requestRelease: (cap, record) => refusing(cap, () => broker.requestRelease(cap, record)),
requestChief: business => broker.requestChief(business),
credentials,
// P2 consumes this inside the bus only. No process IPC/verb returns it.
get modelProxy() { return modelProxyClient; },
@@ -940,6 +1106,7 @@ export async function startBroker({
launches: bound,
readers: readCaps,
async close() {
clearInterval(requestTimer);
clearInterval(usageTimer);
await proxyUsage.close();
clearInterval(modelTimer);
@@ -948,12 +1115,14 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await requestLifecycle.close();
await processing?.close();
store.close();
credentials.close();
},
};
} catch (e) {
clearInterval(requestTimer);
clearInterval(usageTimer);
await proxyUsage?.close();
clearInterval(modelTimer);
+1 -1
View File
@@ -10,7 +10,7 @@ const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
+38 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7';
export const SCHEMA_VERSION = '3n';
export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
export const SCHEMA_VERSION = '3t';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -82,6 +82,36 @@ const migrations = [{
from: '3m',
to: '3n',
sql: readFileSync(new URL('./migrations/3m-3n.sql', import.meta.url), 'utf8'),
}, {
// #797 / #1839 Task 1: append-only request lifecycle evidence. Explicit offline migration only.
from: '3n',
to: '3o',
sql: readFileSync(new URL('./migrations/3n-3o.sql', import.meta.url), 'utf8'),
}, {
// #797 / #1839 Task 2: the request journal's bootstrap.stale and bootstrap.completed kinds. request_events
// is rebuilt with the wider kind list and keeps every row, seq and guard.
from: '3o',
to: '3p',
sql: readFileSync(new URL('./migrations/3o-3p.sql', import.meta.url), 'utf8'),
}, {
from: '3p', to: '3q',
sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'),
}, {
// #797 / #1839 Task 3: the request journal's question.asked, question.answered and attach.selected kinds, for the
// Console intake controls, added to the kind list 3p-3q left (which already holds the PRD kinds and mode.changed).
// request_events is rebuilt with the union and keeps every row, seq and guard.
from: '3q',
to: '3r',
sql: readFileSync(new URL('./migrations/3q-3r.sql', import.meta.url), 'utf8'),
}, {
// Task 6b: checked advancement after Task 3's complete fifteen-kind union.
from: '3r', to: '3s',
sql: readFileSync(new URL('./migrations/3r-3s.sql', import.meta.url), 'utf8'),
}, {
// #797 MVP3: the owner's reversible project archive. Only a new append-only table, its index
// and its guards; no existing row changes and nothing is deleted.
from: '3s', to: '3t',
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -102,6 +132,12 @@ const timedTables = [
'usage_records',
'session_ops',
'message_cancels',
'request_events',
'request_effects',
'request_snapshots',
'request_snapshot_events',
'request_project_events',
'project_archives',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
+11 -5
View File
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -88,6 +88,12 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -95,7 +101,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -145,7 +151,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -157,14 +163,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
@@ -0,0 +1,104 @@
// Lifecycle Task 2 (#797 / Gitea #1839): a confirmed new-project selection becomes a usable project.
// Real startBroker over a real organization file, a real git factory repo and a fixture data root.
// The test barrier stands in for a kill: it throws after a step's effect and before its receipt.
import test from 'node:test';
import assert from 'node:assert/strict';
import cp, { execFileSync } from 'node:child_process';
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'Factory', GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Factory', GIT_COMMITTER_EMAIL: '[email protected]' };
const git = (cwd, ...args) => execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8' }).trim();
const PROJECT_FILE = { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'git.workingBranch': 'main' } };
// The factory: the declared `stack` project is a git repository with a project file.
function factory(root) {
const dir = join(root, 'project');
mkdirSync(join(dir, '.mosaic'), { recursive: true });
writeFileSync(join(dir, '.mosaic/project.json'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
writeFileSync(join(dir, 'README.md'), 'factory\n');
git(dir, 'init', '-q', '-b', 'main'); git(dir, 'add', '-A'); git(dir, 'commit', '-q', '-m', 'factory');
return dir;
}
// `defaultServices` boots with nothing injected, so the host builds its own preparation and admission; `withTrackers` forwards the
// boot config's tracker settings as process.mjs does (the adapter itself is not built).
async function setup(t, { barrier, configureFactory = () => {}, beforePropose = () => {}, defaultServices = false, withTrackers = defaultServices } = {}) {
const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo',d=>{d.roles.assistant={definition:'assistant'};return d;}), source = factory(root);
configureFactory({ root, f, source });
const hooks = { barrier };
const system = loadSystem({ env: f.env });
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
const plans = (dataRoot, ready = () => true) => ({
preparePlans: (state, args, from) => Promise.all(['autonomous', 'guided'].map(mode => prepareBootstrap({
business: writer.read('demo').document, request: state.request, sourceProject: args.source_project, project: args.project, name: args.name,
mode, dataRoot, organizationRevision: writer.read('demo').revision, tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' },
intent: { text: from.text, source_message: from.source_message } }))),
admitPlan: plan => plan.organization_revision === writer.read('demo').revision && ready() ? { ready: true } : { ready: false, code: 'organization-stale' },
barrier: step => hooks.barrier?.(step),
});
const boot = async () => {
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) })));
const bus = await startBroker({ ...config, ...(withTrackers ? { trackers } : {}), requestServices: defaultServices ? {} : plans(f.dataRoot) });
t.after(() => bus.close().catch(() => {}));
return { bus, config, trackers, warnings };
};
const first = await boot();
const caps = { human: first.bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
for (const role of ['pm', 'cto', 'coder', 'chief']) {
caps[role] = first.bus.broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
first.bus.broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args });
const originProject=call('human','project.create',{name:'Origin intake'}).id;
const thread=call('human','thread.create',{project:originProject,name:'Intake'}).id;
caps.assistant=first.bus.broker.bindLaunch({business:'demo',role:'assistant',run:'assistant-run',harness:'pi',thread});
call('assistant','role.claim');
const sent=call('human','thread.send',{thread,body:INTENT});
const relay=call('assistant','message.send',{to:'chief',in_reply_to:sent.id,body:'Original request '+sent.request});
call('chief','message.send',{to:'pm',in_reply_to:relay.id,body:'Open original request '+sent.request});
const opened = await call('pm', 'request.open', { request: sent.request });
// A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back.
let proposed = null, proposeError = null;
beforePropose({ root, f, source });
try {
proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack',
project: 'timer', name: 'Timer', tracker_project: 2 });
} catch (e) { proposeError = e; }
const choose = async (choice = 'guided') => {
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
return first.bus.requestLifecycle.resume('demo', sent.request);
};
const paths = { code: join(f.dataRoot, 'products/demo/timer/code'), records: join(f.dataRoot, 'projects/demo/timer'), product: join(f.dataRoot, 'products/demo/timer') };
const changes = () => { try { return readFileSync(writer.logFile('demo'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); } catch { return []; } };
const additions = () => changes().filter(c => c.state === 'committed' && c.detail?.verb === 'project.add');
return { originProject, thread, root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, proposeError, choose, paths, changes, additions,
rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) };
}
test('archiving the originating chat during bootstrap must fence the next effect',async t=>{
const f=await setup(t,{barrier: step=>{
if(step==='reserve')f.call('human','project.archive',{project:f.originProject});
}});
const state=await f.choose();
console.log('ORIGIN_BOOTSTRAP',JSON.stringify({phase:state.phase,wait:state.wait,codeExists:existsSync(f.paths.code),additions:f.additions().length}));
assert.equal(state.wait?.code,'project-archived');
assert.equal(existsSync(f.paths.code),false);
assert.equal(f.additions().length,0);
});
+2 -1
View File
@@ -34,7 +34,8 @@ const baselines = {
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
"packages/webui/src/bus.mjs": "4ebf6c2b5d71c23d85cbde83f69aa671058dc2195c9ae4bdda624f6940729d1b",
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
"packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3o';")
.replace('}];\nconst timedTables', `}, { from: '3n', to: '3o', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3n ready/);
assert.match(migrated.stdout, /bus schema 3t ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+110 -3
View File
@@ -86,8 +86,12 @@ function setup(t) {
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
}
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
});
@@ -152,7 +156,7 @@ test('preview: the two real answers, a grant summary and the digest, for an open
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
for (const who of ['human', 'reader']) {
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
assert.deepEqual(row.console, { kind: 'grant', options, approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
}
});
@@ -171,6 +175,109 @@ test('J1, J2: a decision that is not an eligible grant request refuses both cons
}
});
test('ordinary three-choice decisions expose authoritative options and resolve the third choice once with Console provenance', (t) => {
const f = setup(t);
const choices = [
{ key: 'new/autonomous', text: 'New project — autonomous' },
{ key: 'new/guided', text: 'New project — guided' },
{ key: 'existing:project', text: 'Add to existing project' },
];
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'How should we handle this request?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
assert.equal(p.kind, 'decision');
assert.equal(p.grant, undefined);
assert.equal(p.approve, undefined);
for (const who of ['human', 'reader']) assert.deepEqual(f.call(who, 'inbox').find(r => r.id === d.id).console.options, choices);
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: choices[2].key });
assert.deepEqual(f.closes(d.id).map(c => [c.op, c.by, c.choice, c.via]), [['resolved', 'jason', choices[2].key, 'console']]);
const inputs = f.events().filter(e => e.event === 'human.input' && e.about === d.id);
assert.equal(inputs.length, 1);
assert.deepEqual(inputs[0].subject, SUBJECT);
assert.equal(inputs[0].via, 'console');
assert.equal(inputs[0].actor_role, null);
assert.equal(inputs[0].actor_run, null);
assert.equal(inputs[0].decisionSeq, f.closes(d.id)[0].seq);
const notices = f.store.all("SELECT * FROM messages WHERE decision=? AND from_role='human' AND class='DECISION'", d.id);
assert.equal(notices.length, 1);
assert.equal(notices[0].to_role, 'chief');
assert.equal(notices[0].from_run, 'human-console');
assert.match(notices[0].body, /resolved by jason: existing:project \(Add to existing project\)/);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest }), 'decision-closed');
assert.equal(f.enrolls.length, 0);
assert.deepEqual(f.grantEvents(d.id), []);
assert.deepEqual(f.make().reconcileGrants(), []);
});
test('ordinary PRD approval and nine non-yes/no choices use the same protocol without grant execution', (t) => {
const f = setup(t);
for (const choices of [options, Array.from({ length: 9 }, (_, i) => ({ key: `choice-${i}`, text: `Choice ${i}` }))]) {
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'Approve this PRD?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'outside', digest: p.digest }), 'decision-stale');
assert.equal(f.closes(d.id).length, 0);
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[0].key, digest: p.digest }), { id: d.id, op: 'resolved', choice: choices[0].key });
assert.deepEqual(f.grantEvents(d.id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
assert.deepEqual(f.closes(id).map(c => [c.by, c.via]), [[route + '-run', 'broker']]);
assert.equal(f.events().filter(e => e.event === 'human.input' && e.about === id).length, 0);
}
});
test('the digest binds ordinary question, action, options, business and raise authorization context', (t) => {
const f = setup(t);
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', target: 'product-1', question: 'Approve draft?', options, recommendation: 'yes', blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
const original = f.store.get('SELECT * FROM decisions WHERE id=?', d.id);
const event = f.store.get("SELECT * FROM events WHERE subject=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise'", d.id);
// Each immutable variant is inserted into a fresh scratch store, never rewritten in place.
for (const change of [
{ question: 'Approve changed draft?' }, { action: 'deploy' },
{ project: 'product-2' }, { spec_commit: 'f'.repeat(40) },
{ options: JSON.stringify([{ key: 'yes', text: 'Changed' }, options[1]]) },
{ authorization: { target: 'product-2' } }, { authorization: { approvalChoice: 'no' } },
]) {
const g = setup(t), { authorization, ...fields } = change;
const row = { ...original, ...fields };
const columns = Object.keys(row);
g.store.run(`INSERT INTO decisions(${columns.join(',')}) VALUES(${columns.map(() => '?').join(',')})`, ...Object.values(row));
const e = { ...event, body: JSON.stringify({ ...JSON.parse(event.body), ...authorization }) };
const ec = Object.keys(e);
g.store.run(`INSERT INTO events(${ec.join(',')}) VALUES(${ec.map(() => '?').join(',')})`, ...Object.values(e));
const current = g.call('console', 'decision.preview.console', { id: d.id });
assert.notEqual(current.digest, p.digest);
refusesSync(() => g.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: p.digest }), 'decision-stale');
assert.deepEqual(g.closes(d.id), []);
}
const other = grantDecision(f.store, { business: 'other', action: 'prd.approve', close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id: other }), 'decision-not-found');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: other, choice: 'yes', digest: p.digest }), 'decision-not-found');
});
test('missing and malformed grant bindings cannot become ordinary Console answers or start an executor', (t) => {
const f = setup(t);
for (const grant of [undefined, {}, { host: 'web1' }, { method: 7 }]) {
const id = grantDecision(f.store, { grant, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
assert.deepEqual(f.closes(id), []);
assert.deepEqual(f.grantEvents(id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
const f = setup(t);
const d = await f.raise();
+91
View File
@@ -0,0 +1,91 @@
import { createGitSocket } from '../../cli/src/git-socket.mjs';
import { pushWorking, fetchReview } from '../../harness/src/git-tools.mjs';
import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { git } from '../../project/tests/prd-fixture.mjs';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
import { DatabaseSync } from 'node:sqlite';
import { createRequestStore } from '../src/request-store.mjs';
const advance = async (f, op = 'next') => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); };
export async function planned(t, threaded = false) {
const f = await requestFixture(t, 'autonomous', null, threaded), sub = await f.submit(); f.approve(sub); await f.activate(sub);
const { createDigest, createMarker } = await import('../src/broker.mjs');
const s = f.journal.readRequest('demo', f.request), task_ref = 'vikunja:2/41';
const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: s.snapshot,
project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] };
prepared.body = { title: 'Timer', description: createMarker(prepared) };
prepared.body_digest = createDigest(prepared.body);
const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared);
let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 };
const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() });
f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } });
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [] });
await assert.rejects(advance(f), /request-evidence-required/);
fields = { ...fields, assignees: [5] };
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] });
const push = commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null });
const review = commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' });
// Lightweight continuation tests simulate the tracker receipt; plannedWithRealClose uses the actual adapter.
return { ...f, task_ref, push, review, closeTask: verdict => {
fields = { ...fields, done: true, bucket: 3 };
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.closed', subject: task_ref, body: { verdict } }] });
} };
}
async function plannedWithRealClose(t) {
const f = await scopedFixture(t), product = await f.product('timer');
const task = await f.create(product.args), task_ref = task.task_ref;
await f.bus.broker.requestTask(f.caps.pm, { verb: 'task.assign', args: { task_ref, role: 'coder' } }, f.adapter.handle);
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
return { ...f, request: product.request, task_ref, store, journal: createRequestStore(store),
push: commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null }),
review: commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' }),
closeTask: verdict => f.bus.broker.requestTask(f.caps.pm, { verb: 'task.close', args: { task_ref, verdict } }, f.adapter.handle) };
}
export async function ready(t, options = {}) {
const f = options.realClose ? await plannedWithRealClose(t) : await planned(t, true);
const advance = async op => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); };
await advance('start');
const code = join(f.f.dataRoot, 'products/demo/timer/code');
const sandbox = join(f.f.dataRoot, 'test-workspaces');
const coder = join(sandbox, 'coder'), reviewer = join(sandbox, 'reviewer'), temp = join(sandbox, 'tmp');
for (const dir of [coder, reviewer, temp, join(f.f.dataRoot, 'bus-host')]) mkdirSync(dir, { recursive: true });
git(coder, 'clone', '-q', code, 'timer'); git(reviewer, 'clone', '-q', code, 'timer');
const clone = join(coder, 'timer'); git(clone, 'checkout', '-qb', 'feat');
const bytes = Buffer.from(`<!doctype html><title>Timer</title><output id="clock">Ready</output><button id="start">Start</button><script>document.querySelector('#start').onclick=()=>{document.querySelector('#clock').textContent='Running';};</script>`);
writeFileSync(join(clone, 'timer.html'), bytes);
if (options.invalidPaths) { symlinkSync('timer.html', join(clone, 'link.html')); writeFileSync(join(clone, 'large.html'), Buffer.alloc(1024 * 1024 + 1, 65)); }
git(clone, 'add', '-A'); git(clone, 'commit', '-qm', 'Build timer');
const commit = git(clone, 'rev-parse', 'HEAD');
const host = { op: async r => {
if (r.op === 'identity') return f.bus.identity(r.cap);
if (r.op === 'checkPush') return f.bus.checkPush(r.cap, r.task_ref, r.project);
if (r.op === 'recordPush') return f.bus.recordPush(r.cap, r.push);
if (r.op === 'reviewFetch') return f.bus.reviewFetch(r.cap, r.args);
if (r.op === 'refuse') return;
throw new Error('unexpected socket operation');
} };
const socket = createGitSocket({ host, business: { id: 'demo', projects: { timer: { root: code } } }, dataRoot: f.f.dataRoot, env: f.f.env });
await socket.listen(); t.after(() => socket.close());
const pushed = await pushWorking({ path: socket.path, cap: f.caps.coder, workspace: coder, task_ref: f.task_ref, project: 'timer', branch: 'feat', tmp: temp, env: f.f.env });
assert.equal(pushed.commit, commit);
const review = f.review(commit); await advance('review');
const fetched = await fetchReview({ path: socket.path, cap: f.caps.reviewer, workspace: reviewer, task_ref: f.task_ref, request: review.request, tmp: temp, env: f.f.env });
assert.equal(fetched.commit, commit);
assert.equal(git(fetched.clone, 'show', `${fetched.commit}:timer.html`), bytes.toString());
const results = 'Acceptance: timer starts and stops offline; passed.';
const record = JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: review.request, commit, results });
const verification = await f.call('reviewer', 'record.write', { project: 'timer', path: 'docs/verification.md', record });
const verdictBody = JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results: options.results ?? results });
const verdict = f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: review.request, verdict: 'approve', body: verdictBody });
await advance('delivery'); await f.closeTask(verdict.verdict);
const s = f.journal.readRequest('demo', f.request);
const args = { request: f.request, revision: s.revision, op: 'deliver-1', task_ref: f.task_ref, verdict: verdict.verdict, path: 'timer.html', verification_record: { path: verification.path, commit: verification.commit } };
return { ...f, advance, bytes, commit, review, verdict, verification, args };
}
+12 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -78,6 +78,12 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -99,7 +105,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -125,7 +131,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -136,14 +142,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+12 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -81,6 +81,12 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -104,7 +110,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -129,7 +135,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -140,14 +146,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy'];
'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy', 'project.bootstrap'];
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
+67
View File
@@ -6,6 +6,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { createContinuations } from '../src/request-continuation.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
// Row 146 (#1640): a message.send to a role nobody holds tells the business's launch.by
@@ -35,6 +36,72 @@ const demo = (extra = {}) => ({
},
});
test('continuation notices ignore unrelated lifecycle events, halts, and unclaimed launches', async t => {
const root = mkdtempSync(join(tmpdir(), 'continuation-notice-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const extraRoles = { ...roles, reviewer: { definition: 'reviewer', authority: { withinRole: ['message.send'], crossRole: [] } }, helper: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } };
const businesses = demo({ roles: extraRoles, definitions: { ...definitions, reviewer: {} }, launch: { by: 'chief', instances: ['pm', 'coder', 'reviewer'], models: {}, limits: {} } });
const states = ['pm', 'coder', 'reviewer'].map((role, i) => ({ business: 'demo', request: `request-${i}`, revision: 1, phase: 'selection', pm: role, source_message: `source-${i}`, tasks: [] }));
const rows = [];
const journal = {
effects: (business, request) => rows.filter(r => r.business === business && r.request === request),
transaction: fn => fn(),
prepareEffect: e => { rows.push(structuredClone(e)); return e; },
finishEffect: e => { rows.push(structuredClone(e)); return e; },
};
let halted = false, continuations;
const processing = { read: () => ({ version: 1, generation: '00000000-0000-4000-8000-000000000000', halted }) };
const b = new Broker({ store, businesses, processing, requestLifecycle: services => {
continuations = createContinuations({ journal, services });
const noop = () => {};
return { open: noop, get: noop, projects: noop, propose: noop, question: noop, answer: noop, attach: noop, cancel: noop, modeConsole: noop, draftConsole: noop, resume: noop, recover: noop };
} });
const resume = () => { for (const state of states) { try { continuations.resume(state); } catch (e) { if (e.code !== 'processing-halted') throw e; } } };
const notices = id => store.get("SELECT count(*) n FROM messages WHERE from_role='_bus' AND to_role='chief' AND in_reply_to=?", id).n;
const launch = (role, run) => b.bindLaunch({ business: 'demo', role, run, harness: 'pi' });
launch('helper', 'helper-old');
resume();
const waiting = store.all("SELECT id,to_role FROM messages WHERE from_role='_bus' AND body LIKE 'Request continuation %' ORDER BY seq");
assert.equal(waiting.length, 3);
for (const m of waiting) assert.equal(notices(m.id), 1);
halted = true;
b.endLaunch({ business: 'demo', run: 'helper-old', reason: 'exit' });
resume();
for (const m of waiting) assert.equal(notices(m.id), 1, 'halt sends no notices');
halted = false;
launch('helper', 'helper-new');
resume();
for (const m of waiting) assert.equal(notices(m.id), 1, 'unrelated launch sends no notices');
const pm = launch('pm', 'pm-new');
resume();
for (const m of waiting) assert.equal(notices(m.id), 1, 'unclaimed target launch sends no notices');
b.request(pm, { verb: 'role.claim', args: {} });
resume();
const pmMessage = waiting.find(m => m.to_role === 'pm');
assert.equal(store.get("SELECT holder_run FROM deliveries WHERE message=? AND op='routed' ORDER BY seq DESC LIMIT 1", pmMessage.id).holder_run, 'pm-new');
launch('coder', 'coder-new');
resume();
for (const m of waiting) assert.equal(notices(m.id), 1, 'staggered launches do not duplicate notices');
states.push({ business: 'demo', request: 'request-starting', revision: 1, phase: 'selection', pm: 'coder', source_message: 'source-starting', tasks: [] });
resume();
const starting = store.get("SELECT id FROM messages WHERE to_role='coder' AND body LIKE '%request-starting%'");
assert.equal(notices(starting.id), 0, 'a new continuation does not announce an unclaimed launch');
halted = true;
b.endLaunch({ business: 'demo', run: 'coder-new', reason: 'exit' });
resume();
assert.equal(notices(starting.id), 0, 'target end while halted sends no notice');
halted = false;
resume();
assert.equal(notices(starting.id), 1, 'target end is noticed after processing resumes');
const replacement = launch('coder', 'coder-replacement');
b.request(replacement, { verb: 'role.claim', args: {} });
resume();
assert.equal(store.get("SELECT holder_run FROM deliveries WHERE message=? AND op='routed' ORDER BY seq DESC LIMIT 1", starting.id).holder_run, 'coder-replacement');
});
function setup(t, businesses = demo()) {
const root = mkdtempSync(join(tmpdir(), 'bus-notice-'));
let store = new Store(root);
@@ -77,6 +77,11 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
case 'lifecycleDelete':return bus.lifecycleDelete(m.record);
case 'lifecycleDone':return bus.lifecycleDone(m.record);
case 'sessionClears':return bus.sessionClears(m.business);
case 'request.launches':return bus.requestLaunches(m.business);
case 'request.launchResult':return bus.requestLaunchResult(m.record);
case 'request.chief':return bus.requestChief(m.business);
case 'request.manifest':return bus.requestManifest(m.record);
case 'request.admission':return bus.admission(m);
default:throw Error('fixture unrequested trusted operation');
}
};
@@ -3,12 +3,14 @@ import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { getPriority, setPriority } from 'node:os';
import { revocationFixture, outcome } from './model-revocation-fixture.mjs';
setPriority(0,19);
const cleanup=[];let answer;
try {
const f=await revocationFixture({after:close=>cleanup.push(close)},{onRoot:root=>process.send?.({kind:'owned-root',root})});
f.seed();fs.unlinkSync(f.temporary);const made=spawnSync('/usr/bin/mkfifo',['-m','600',f.temporary],{stdio:'ignore',timeout:5000,killSignal:'SIGTERM'});
if(made.status!==0)throw Error('owned fifo setup failed');
// Fixture setup can outlive a desktop scheduler's initial process classification.
// Set the measured priority at the controlled operation boundary, after setup.
setPriority(0,19);
await new Promise((resolve,reject)=>process.send({kind:'ready',nice:getPriority(0)},error=>error?reject(error):resolve()));
const began=Date.now(),result=await outcome(async()=>{await f.prepare();f.complete();});
answer={kind:'result',ok:result.ok,code:result.code??null,cachedRevoking:f.record()?.state==='revoking',finalPresent:f.exists(f.file),tmpPresent:f.exists(f.temporary),openModelFds:f.openModelFds(),elapsedMs:Date.now()-began,nice:getPriority(0)};f.valueFree();
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
function archiveOrigin(f) {
const state=f.journal.readRequest('demo',f.request);
const p=f.store.get('SELECT project FROM threads WHERE id=?',state.thread).project;
f.call('human','project.archive',{project:p});
return p;
}
test('origin archive must fence later PRD activation', async t=>{
const f=await requestFixture(t,'autonomous',null,true);
const sub=await f.submit(); f.approve(sub); archiveOrigin(f);
const before=f.journal.snapshotEvents('demo','timer').length;
let value=null,error=null;
try {value=await f.activate(sub);}catch(e){error=e.code;}
console.log('ORIGIN_ARCHIVE_ACTIVATION',JSON.stringify({error,snapshot:value?.id,phase:f.journal.readRequest('demo',f.request).phase}));
assert.equal(error,'project-archived');
assert.equal(value,null); assert.equal(f.journal.snapshotEvents('demo','timer').length,before);
assert.equal(f.journal.readRequest('demo',f.request).phase,'prd-review');
});
test('origin archive must fence task and requirement admission', async t=>{
const f=await requestFixture(t,'autonomous',null,true);
const sub=await f.submit();f.approve(sub);const snap=await f.activate(sub);archiveOrigin(f);
const args={request:f.request,project_key:'timer',requirement_snapshot:snap.id,op:'new-after-origin-archive',requirement:'REQ-POMO-1'};
assert.throws(() => f.bus.broker.taskCreateTarget(f.caps.pm,args), e => e.code === 'project-archived');
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm,args), e => e.code === 'project-archived');
});
+332
View File
@@ -0,0 +1,332 @@
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { prdFixture, git } from '../../project/tests/prd-fixture.mjs';
import { createRequestStore } from '../src/request-store.mjs';
async function setup(t, mode = 'autonomous') {
const root = tmp(t, 'activation-'), f = fixture(root, 'demo');
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1 } }));
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
const system = loadSystem({ env: f.env });
const boot = async () => {
const config = bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} });
const bus = await startBroker(config); t.after(() => bus.close().catch(() => {})); return bus;
};
const bus = await boot(), caps = {};
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
const bind = b => {
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.console = b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
caps.reader = b.bindReader({ business: 'demo' });
for (const role of ['pm','cto','coder']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); }
};
bind(bus.broker);
const call = (who, verb, args = {}) => bus.broker.request(caps[who], { verb, args });
const sent = call('human', 'message.send', { to: 'pm', body: 'Build a timer.\n' });
let state = await call('pm', 'request.open', { request: sent.request });
state = await call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'plan', source_project: 'stack', project: 'timer', name: 'Timer', tracker_project: 2 });
call('human', 'decision.resolve', { id: state.selection.decision, choice: mode });
state = await bus.requestLifecycle.resume('demo', sent.request);
const context = { business: { id: 'demo', human: 'jason', projects: { timer: { root: join(f.dataRoot, 'products/demo/timer/code') } } }, project: 'timer', dataRoot: f.dataRoot };
const p = await prdFixture(t, context);
const args = { request: sent.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: p.args.records_commit, prd_path: p.args.prd_path, requirement_ids: p.args.requirement_ids };
const submit = () => call('pm', 'prd.submit', args);
const approve = (submission, who = mode === 'guided' ? 'human' : 'cto') => call(who, 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
const activate = submission => bus.requirementCatalog.activate({ request: sent.request, decision: submission.decision.id });
return { ...p, f, bus, caps, call, state, request: sent.request, args, submit, approve, activate, boot, bind, config: bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} }), store, journal: createRequestStore(store) };
}
test('autonomous PM submits technical PRD to distinct CTO, no task before atomic idempotent activation', async t => {
const f = await setup(t), submission = await f.submit();
assert.equal(submission.decision.action, 'prd.approve'); assert.equal(submission.decision.route_to, 'cto');
assert.equal(submission.decision.authorization.target.startsWith('prd-'), true);
assert.equal(submission.decision.spec_commit, submission.candidate.spec_commit); assert.equal(submission.decision.blocking, false);
assert.equal(f.store.get('SELECT count(*) n FROM task_snapshots').n, 0);
await assert.rejects(f.activate(submission), { code: 'prd-not-approved' });
assert.throws(() => f.call('pm', 'decision.resolve', { id: submission.decision.id, choice: 'yes' }), { code: 'not-resolver' });
f.approve(submission);
f.call('cto', 'role.release');
const snap = await f.activate(submission);
assert.equal(snap.requirements[0].text, 'A working timer.'); assert.equal(snap.origin, 'product');
assert.deepEqual(await f.activate(submission), snap);
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 1);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'planning');
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'prd-activate' && e.stage === 'committed').length, 1);
assert.equal((await f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id, requirement: 'REQ-POMO-1' })).id, snap.id);
});
test('guided routes the same PRD action to owner without a routing citation', async t => {
const f = await setup(t, 'guided'), sub = await f.submit();
assert.equal(sub.decision.route_to, 'human'); assert.equal(sub.decision.spec_commit, null);
assert.throws(() => f.approve(sub, 'cto'), { code: 'human-required' });
f.approve(sub); assert.equal((await f.activate(sub)).approval.by, 'jason');
});
test('submit is PM-only, strict, revision checked, idempotent and size rejected before a decision', async t => {
const f = await setup(t);
for (const who of ['coder','cto','human','reader']) await assert.rejects(Promise.resolve().then(() => f.call(who, 'prd.submit', f.args)));
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, prd_blob: 'forged' }));
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, revision: 1 }), { code: 'request-revision-conflict' });
const count = f.store.get('SELECT count(*) n FROM decisions').n;
const records_commit = f.save('docs/prd-1.0.md', 'x'.repeat(32769));
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, records_commit }), { code: 'prd-candidate-size' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
});
for (const path of ['docs/prd-1.0.md','requirements/REQ-POMO-1.json','spec/north-star.md']) test(`activation rereads current selected content and intent: ${path}`, async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
f.save(path, path.endsWith('.json') ? JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Other', text: 'Other', status: 'accepted' }) : 'Changed\n');
await assert.rejects(f.activate(sub));
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
});
test('forged accepted status is no authority, wrong project and business cannot borrow a snapshot', async t => {
const f = await setup(t);
f.args.records_commit = f.save('requirements/REQ-POMO-1.json', JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'A working timer.', status: 'accepted' }), 'pm.pm');
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer' }), { code: 'requirement-snapshot-required' });
const sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'stack', requirement_snapshot: snap.id }));
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'other', project: 'timer', snapshot: snap.id }));
});
test('fresh runtime resolves identical approved snapshot from committed records and journal alone', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
await f.bus.close(); const next = await f.boot();
assert.deepEqual(await next.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), snap);
});
test('owner-only explicit revocation rejects stale revisions and makes pending work wait for replacement', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
const args = { project: 'timer', snapshot: snap.id, revision: 1, reason: 'Replace requirements', op: 'revoke1' };
for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'prd.revoke.console', args), { code: 'human-required' });
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, revision: 0 }));
const revoked = await f.call('console', 'prd.revoke.console', args);
assert.deepEqual(await f.call('console', 'prd.revoke.console', args), revoked);
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
assert.equal(f.journal.readRequest('demo', f.request).wait.code, 'requirement-snapshot-revoked');
});
test('mode and spec changes invalidate admissions and active snapshots', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
let state = f.journal.readRequest('demo', f.request);
await f.call('console', 'request.mode.console', { request: f.request, revision: state.revision, mode_revision: state.mode_revision, mode: 'guided', op: 'mode1' });
await assert.rejects(f.activate(sub));
state = f.journal.readRequest('demo', f.request);
const next = await f.call('pm', 'prd.submit', { ...f.args, revision: state.revision, op: 'prd2' });
f.approve(next, 'human'); const snap = await f.activate(next);
const spec = await f.call('human', 'project.file', { project: 'timer', path: 'spec/north-star.md' });
await f.call('human', 'project.file.save', { project: 'timer', path: spec.path, base: spec.commit, content: 'Changed intent\n' });
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
});
for (const fault of ['missing-authority','self-instance','same-definition']) test(`autonomous arbiter ${fault} is a setup wait without a decision`, async t => {
const f = await setup(t), b = structuredClone(f.config.businesses.demo);
if (fault === 'missing-authority') b.roles.cto.authority.withinRole = b.roles.cto.authority.withinRole.filter(a => a !== 'decision.resolve.technical');
if (fault === 'self-instance') b.arbiters.technical = 'pm';
if (fault === 'same-definition') b.roles.cto.definition = 'pm';
f.bus.broker.reloadBusiness('demo', b);
const count = f.store.get('SELECT count(*) n FROM decisions').n, result = await f.submit();
assert.equal(result.wait.kind, 'setup'); assert.match(result.wait.code, /^prd-(arbiter-unavailable|self-approval)$/);
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
});
test('activation rechecks current role authority and cannot use another candidate decision', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
await assert.rejects(f.bus.requirementCatalog.activate({ request: f.request, decision: f.state.selection.decision }), { code: 'prd-candidate-required' });
const b = structuredClone(f.config.businesses.demo); b.roles.cto.authority.withinRole = b.roles.cto.authority.withinRole.filter(a => a !== 'decision.resolve.technical');
f.bus.broker.reloadBusiness('demo', b);
await assert.rejects(f.activate(sub), { code: 'prd-arbiter-unavailable' });
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
});
test('submission replay has one decision and rejects a changed operation binding', async t => {
const f = await setup(t), sub = await f.submit();
assert.deepEqual(await f.submit(), sub);
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, prd_version: '2.0' }), { code: 'request-operation-conflict' });
});
test('changed current intent is refused before submission creates a decision', async t => {
const f = await setup(t), count = f.store.get('SELECT count(*) n FROM decisions').n;
f.save('spec/north-star.md', 'Changed spec\n');
await assert.rejects(f.submit(), { code: 'stale-spec' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
});
test('activation rollback leaves no snapshot, receipt or continuation after an insert failure', async t => {
const f = await setup(t), sub = await f.submit();
// A host fixture trigger injects a failure after the snapshot insert, before its activation event.
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'));
db.exec("CREATE TRIGGER reject_activation BEFORE INSERT ON request_snapshot_events BEGIN SELECT RAISE(ABORT,'fixture-interruption'); END");
f.approve(sub);
await assert.rejects(f.activate(sub));
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'prd-activate').length, 0);
assert.notEqual(f.journal.readRequest('demo', f.request).phase, 'planning');
db.exec('DROP TRIGGER reject_activation'); db.close();
});
test('owner draft read shows exact bound PRD and requirements without a generic file endpoint', async t => {
const f = await setup(t), sub = await f.submit();
const draft = await f.call('console', 'request.draft.console', { request: f.request });
assert.deepEqual(draft.candidate, sub.candidate); assert.equal(draft.prd, '# Timer 1.0\nA timer.\n'); assert.equal(draft.requirements[0].id, 'REQ-POMO-1');
for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' });
});
test('owner draft snapshot counter follows activation and revoke independently of the request revision', async t => {
const f = await setup(t);
const read = () => f.call('console', 'request.draft.console', { request: f.request });
assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } });
const sub = await f.submit();
assert.deepEqual((await read()).snapshot, { id: null, revision: 0 });
f.approve(sub);
const snap = await f.activate(sub), active = await read();
assert.deepEqual(active.snapshot, { id: snap.id, revision: 1 });
assert.notEqual(active.snapshot.revision, f.journal.readRequest('demo', f.request).revision);
const args = { project: 'timer', snapshot: active.snapshot.id, revision: active.snapshot.revision, reason: 'Replace this version', op: 'revoke-read' };
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, revision: f.journal.readRequest('demo', f.request).revision }), { code: 'request-revision-conflict' });
const receipt = await f.call('console', 'prd.revoke.console', args);
assert.equal(receipt.kind, 'revoked');
assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } });
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, op: 'stale-read' }), { code: 'request-revision-conflict' });
for (const who of ['pm', 'cto', 'coder', 'human', 'reader'])
assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' });
// The next submission sees the existing project counter even though it has no active snapshot yet.
await f.call('pm', 'prd.submit', { ...f.args, revision: f.journal.readRequest('demo', f.request).revision, op: 'prd-again' });
assert.deepEqual((await read()).snapshot, { id: null, revision: 2 });
});
test('3p through 3s migration preserves existing request rows and the append-only guards', async t => {
const { readFileSync, chmodSync } = await import('node:fs');
const { Store, schemaDigest } = await import('../src/store.mjs');
const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3p');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', at);
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m',?,'demo','human','human','pm','INFO','Timer')").run(at);
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r',?,'demo','human.input','m',?)").run(at, JSON.stringify({ kind: 'instruction', message: 'm' }));
db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',1,'opened',?,'{}')").run(at);
const before = db.prepare('SELECT * FROM request_events').all();
assert.throws(() => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')").run(at));
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',4,'prd.activated',?,'{}')", at), /revision-conflict/);
});
test('guided Console approval uses the existing confirmation digest and attributed resolution evidence', async t => {
const f = await setup(t, 'guided'), sub = await f.submit();
const preview = f.call('console', 'decision.preview.console', { id: sub.decision.id });
f.call('console', 'decision.resolve.console', { id: sub.decision.id, choice: 'yes', digest: preview.digest });
const snap = await f.activate(sub); assert.equal(snap.approval.via, 'console');
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE decision=? AND class='DECISION'", sub.decision.id).n, 1);
});
test('declined draft returns to discovery and mode change revokes an active snapshot', async t => {
const f = await setup(t), sub = await f.submit();
f.call('cto', 'decision.resolve', { id: sub.decision.id, choice: 'no' });
const discovery = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(discovery.phase, 'discovery'); assert.equal(discovery.wait.code, 'prd-changes-requested');
const next = await f.call('pm', 'prd.submit', { ...f.args, revision: discovery.revision, op: 'revision2' });
f.approve(next); const snap = await f.activate(next);
const state = f.journal.readRequest('demo', f.request);
await f.call('console', 'request.mode.console', { request: f.request, revision: state.revision, mode_revision: state.mode_revision, mode: 'guided', op: 'mode2' });
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
});
const archiveProduct = f => {
const p = f.store.get("SELECT id FROM projects WHERE business='demo' AND repo='timer'");
assert.ok(p);
f.call('human', 'project.archive', { project: p.id });
return p.id;
};
test('archive fences product admission and PRD submission by repository key', async t => {
const f = await setup(t), count = f.store.get('SELECT count(*) n FROM decisions').n;
const p = archiveProduct(f);
assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).code, 'project-archived');
assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).ready, false);
assert.equal(f.bus.admission({ business: 'demo', project: 'stack' }).ready, true);
await assert.rejects(f.submit(), { code: 'project-archived' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
f.call('human', 'project.unarchive', { project: p });
assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).ready, true);
assert.ok(await f.submit());
});
test('archive after PRD approval refuses activation without publishing a snapshot', async t => {
const f = await setup(t), sub = await f.submit();
f.approve(sub); archiveProduct(f);
await assert.rejects(f.activate(sub), { code: 'project-archived' });
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
});
test('archived products refuse task targets and requirements admission but retain snapshot reads and cancellation', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
const snap = await f.activate(sub);
archiveProduct(f);
const args = { request: f.request, project_key: 'timer', requirement_snapshot: snap.id, op: 'new-task', requirement: 'REQ-POMO-1' };
assert.throws(() => f.bus.broker.taskCreateTarget(f.caps.pm, args), { code: 'project-archived' });
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, args), { code: 'project-archived' });
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id }), { code: 'project-archived' });
assert.equal((await f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id })).id, snap.id);
const state = await f.call('console', 'request.get.console', { request: f.request });
const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: state.revision, op: 'cancel-archived' });
assert.equal(cancelled.phase, 'cancelled');
assert.deepEqual(await f.bus.requestLifecycle.resume('demo', f.request), cancelled, 'archive recovery must not rewrite terminal request state');
});
test('archived products disappear from the request picker and refuse attach/propose explicitly', async t => {
const f = await setup(t);
const sent = f.call('human', 'message.send', { to: 'pm', body: 'New work.' });
let state = await f.call('pm', 'request.open', { request: sent.request });
state = await f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'another-plan', source_project: 'stack', project: 'new-timer', name: 'New Timer', tracker_project: 3 });
f.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' });
state = await f.bus.requestLifecycle.resume('demo', sent.request);
assert.ok((await f.call('console', 'request.projects.console', { request: sent.request })).some(p => p.project === 'timer'));
archiveProduct(f);
assert.ok(!(await f.call('console', 'request.projects.console', { request: sent.request })).some(p => p.project === 'timer'));
await assert.rejects(f.call('console', 'request.attach.console', { request: sent.request, revision: state.revision, project: 'timer', op: 'archived-pick' }), { code: 'project-archived' });
await assert.rejects(f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'archived-plan', source_project: 'timer', project: 'timer', name: 'Timer', tracker_project: 2 }), { code: 'project-archived' });
});
test('archive stops new continuations and request-bound launch/manifest recovery', async t => {
const f = await setup(t);
const before = f.store.get('SELECT count(*) n FROM messages').n;
archiveProduct(f);
await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
assert.ok(!f.bus.requestLaunches('demo').some(i => i.project === 'timer'));
assert.throws(() => f.bus.requestManifest({ business: 'demo', instance: 'pm', project: 'timer', run: 'pm-run' }), { code: 'project-archived' });
});
test('a pending resource plan cannot schedule intake from an archived source product', async t => {
const f = await setup(t);
const sent = f.call('human', 'message.send', { to: 'pm', body: 'Build another timer.' });
let state = await f.call('pm', 'request.open', { request: sent.request });
state = await f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'pending-plan',
source_project: 'timer', project: 'another-timer', name: 'Another timer', tracker_project: 3 });
archiveProduct(f);
f.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'guided' });
const before = f.store.get('SELECT count(*) n FROM messages').n;
state = await f.bus.requestLifecycle.resume('demo', sent.request);
assert.equal(state.wait.code, 'project-archived');
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
assert.ok(!f.bus.requestLaunches('demo').some(i => i.request === sent.request));
});
test('activation rechecks archive after asynchronous candidate reads', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
const activating = f.activate(sub);
archiveProduct(f);
await assert.rejects(activating, { code: 'project-archived' });
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
});
test('requirements admission rechecks archive after asynchronous catalog reads', async t => {
const f = await setup(t), sub = await f.submit(); f.approve(sub);
const snap = await f.activate(sub);
const admission = f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id });
archiveProduct(f);
await assert.rejects(admission, { code: 'project-archived' });
});
+281
View File
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mjs';
// #797 MVP3 (owner report 2026-11-06): the owner's reversible project archive. The verbs are the
// human's alone (tester, user and agent refused), an archived project hides from project.list
// until includeArchived asks for it, refuses new threads and requests, keeps every record
// readable, and unarchive restores everything. Nothing is ever deleted.
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
const AT = '2026-11-05T00:00:00.000Z';
const business = {
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
roles: {
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { continuity: { messaging: shipped('continuity').messaging }, 'chief-of-staff': {} },
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
};
function setup(t, { grants = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-project-archive-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: grants ?? [] } };
const b = new Broker({ store, businesses: { demo: business }, access: () => access.doc }); const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (verb, args = {}, who = 'human') => b.request(caps[who], { verb, args });
const code = (verb, args = {}, who = 'human') => {
try { call(verb, args, who); } catch (e) { return e.code; }
return null;
};
caps.agent = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
call('role.claim', {}, 'agent');
if (grants) caps.user = b.bindUser({ business: 'demo', user: 'alice' });
return { b, caps, store, call, code, root, access };
}
const project = (f, name = 'demo-project') => f.call('project.create', { name }).id;
test('project.archive and project.unarchive are the human\'s alone', (t) => {
const f = setup(t), id = project(f);
for (const list of [TESTER_VERBS, USER_VERBS, VIEWER_VERBS])
assert.ok(!list.includes('project.archive') && !list.includes('project.unarchive'));
assert.equal(f.code('project.archive', { project: id }, 'tester'), 'tester-refused');
assert.equal(f.code('project.archive', { project: id }, 'agent'), 'human-required');
assert.throws(() => f.b.bindHuman({ business: 'demo', human: 'not-owner', via: 'cli', outsideAgent: true }), { code: 'human-required' });
assert.throws(() => f.b.bindConsole({ business: 'demo', human: 'not-owner', subject: { provider: 'test', iss: 'test', sub: 'other' } }), { code: 'human-required' });
f.caps.console = f.b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
assert.equal(f.code('project.archive', { project: id }, 'console'), 'human-required');
const u = setup(t, { grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['continuity'], level: 'collaborator' }] });
assert.equal(u.code('project.archive', { project: id }, 'user'), 'access-refused');
assert.equal(u.code('project.unarchive', { project: id }, 'user'), 'access-refused');
assert.deepEqual(f.call('project.archive', { project: id }), { project: id, archived: true });
assert.deepEqual(f.call('project.unarchive', { project: id }), { project: id, archived: false });
});
test('archive verbs validate arguments and unknown projects', (t) => {
const f = setup(t), id = project(f);
assert.equal(f.code('project.archive', {}), 'invalid-request');
assert.equal(f.code('project.archive', { project: id, extra: 1 }), 'invalid-request');
// identifier() takes any id-shaped string, so a non-uuid name simply finds no project.
assert.equal(f.code('project.archive', { project: 'not-a-uuid' }), 'project-not-found');
assert.equal(f.code('project.archive', { project: '00000000-0000-4000-8000-000000000009' }), 'project-not-found');
assert.equal(f.code('project.unarchive', { project: id }), 'project-not-archived');
f.call('project.archive', { project: id });
assert.equal(f.code('project.archive', { project: id }), 'project-already-archived');
// A repeat op never writes a second row.
assert.equal(f.store.get('SELECT count(*) n FROM project_archives').n, 1);
});
test('project.list hides archived projects until includeArchived, which flags them', (t) => {
const f = setup(t);
const live = project(f, 'live'), gone = project(f, 'gone');
f.call('project.archive', { project: gone });
assert.deepEqual(f.call('project.list').map((p) => p.id), [live]);
assert.ok(f.call('project.list').every((p) => !('archived' in p)));
const all = f.call('project.list', { includeArchived: true });
assert.deepEqual(all.map((p) => p.id), [live, gone]);
assert.deepEqual(all.map((p) => p.archived), [undefined, true]);
assert.equal(f.code('project.list', { includeArchived: 'yes' }), 'invalid-request');
assert.equal(f.code('project.list', { includeArchived: false }), 'invalid-request');
// FED R3: a user still lists granted projects only, archived or not. Grants name project ids.
const u = setup(t);
const uGone = project(u, 'gone');
u.access.doc.grants.push({ id: 'g-1', user: 'alice', business: 'demo', projects: [uGone], instances: ['continuity'], level: 'collaborator' });
u.caps.user = u.b.bindUser({ business: 'demo', user: 'alice' });
assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), [uGone]);
assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => p.id), [uGone]);
u.call('project.archive', { project: uGone });
assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), []);
assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => [p.id, p.archived]), [[uGone, true]]);
});
test('an archived project refuses new threads and requests, keeps records readable, and unarchive restores everything', (t) => {
const f = setup(t), id = project(f);
const thread = f.call('thread.create', { project: id, name: 'continuity', instance: 'continuity' }).id;
assert.ok(f.call('thread.send', { thread, body: 'before archive' }).id);
const rows = () => ({
threads: f.store.get('SELECT count(*) n FROM threads').n,
messages: f.store.get('SELECT count(*) n FROM messages').n,
events: f.store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n,
});
f.call('room.open', { project: id });
const before = rows();
f.call('project.archive', { project: id });
assert.equal(f.code('thread.create', { project: id, name: 'another', instance: 'continuity' }), 'project-archived');
assert.equal(f.code('thread.send', { thread, body: 'while archived' }), 'project-archived');
assert.equal(f.code('room.open', { project: id }), 'project-archived');
assert.equal(f.code('room.send', { project: id, body: 'while archived' }), 'project-archived');
assert.deepEqual(rows(), before, 'an archived project writes no thread, message or event');
// Existing records stay readable.
assert.deepEqual(f.call('thread.list', { project: id }).map((r) => r.id), [thread]);
assert.equal(f.call('thread.messages', { thread }).length, 1);
assert.ok(Array.isArray(f.call('room.read', { project: id })));
// Unarchive restores everything.
f.call('project.unarchive', { project: id });
assert.ok(f.call('project.list').some((p) => p.id === id));
assert.ok(f.call('thread.create', { project: id, name: 'another', instance: 'continuity' }).id);
assert.ok(f.call('thread.send', { thread, body: 'after unarchive' }).id);
assert.ok(f.call('room.send', { project: id, body: 'after unarchive' }).ids.length > 0);
// The audit trail: one row per change, append-only, naming who and when.
assert.deepEqual(f.store.all('SELECT business,project,op,by FROM project_archives').map((r) => ({ ...r })), [
{ business: 'demo', project: id, op: 'archive', by: 'jason' },
{ business: 'demo', project: id, op: 'unarchive', by: 'jason' },
]);
assert.match(f.store.get('SELECT at FROM project_archives').at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/);
});
// --- The 3s→3t migration: project_archives is new and empty, and everything else stays. ---
const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(ddl3s);
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3s');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', AT);
db.prepare("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p1',?,'demo','kept',NULL,'jason')").run(AT);
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved',?,'demo','action.allowed','{}')").run(AT);
db.close(); chmodSync(path, 0o600);
return { root, path };
}
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })); }
finally { db.close(); }
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; }
finally { db.close(); }
};
test('a 3s store migrates to 3t: project_archives is new and empty, every row stays, and the schema is the fresh one', (t) => {
const { root, path } = source3s(t);
const was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.equal(version(path), '3s');
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3t');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'project-archive-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// The guards: append-only, state changes only, and a row belongs to its project's business.
const next = (op, p = 'p1') => store.run('INSERT INTO project_archives(at,business,project,op,by) VALUES(?,?,?,?,?)', AT, 'demo', p, op, 'jason');
next('archive');
assert.throws(() => next('archive'), /a project archive row changes state/);
next('unarchive');
for (const [sql, ...args] of [["UPDATE project_archives SET op='unarchive'"], ['DELETE FROM project_archives'],
["INSERT OR REPLACE INTO project_archives(seq,at,business,project,op,by) VALUES(1,?,'demo','p1','unarchive','jason')", AT]])
// The replace also trips the state guard when its op repeats the latest row's.
assert.throws(() => store.run(sql, ...args), /project_archives is append-only|a project archive row changes state/, sql);
// A row naming a project that does not exist is refused (the business guard fires first).
assert.throws(() => next('archive', '00000000-0000-4000-8000-000000000009'), /FOREIGN KEY|an archive row belongs to its project's business/);
assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'other','p1','archive','jason')", AT), /an archive row belongs to its project's business/);
assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'demo','p1','grant','jason')", AT), /CHECK/);
assert.equal(store.get('SELECT count(*) n FROM project_archives').n, 2);
});
test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3s(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path);
const moduleRoot = join(root, 'engine-3s');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3s);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
new Store3s(old).close();
});
test('archived threads refuse open, clear and fresh claims, including metadata-only sessions', t => {
const f = setup(t), p = project(f);
const thread = f.call('thread.create', { project: p, name: 'archive-admission', instance: 'continuity' }).id;
const session = f.call('session.list', { thread }).find(s => s.default);
f.call('thread.open', { thread }); // queued before archive
const cap = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'queued', harness: 'pi', thread, session: session.id });
f.call('project.archive', { project: p });
const named = f.call('session.create', { thread, name: 'metadata-only', op: 'metadata-only' }).id;
for (const se of [session.id, named]) {
assert.equal(f.code('thread.open', { thread, session: se }), 'project-archived');
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'new-' + se, harness: 'pi', thread, session: se }), { code: 'project-archived' });
}
assert.equal(f.code('session.clear', { thread, session: session.id, revision: session.revision, confirm: true, op: 'archived-clear' }), 'project-archived');
assert.throws(() => f.b.request(cap, { verb: 'role.claim' }), { code: 'project-archived' });
assert.equal(f.store.get('SELECT count(*) n FROM session_ops').n, 0);
assert.equal(f.store.get('SELECT count(*) n FROM thread_claims').n, 0);
f.call('project.unarchive', { project: p });
assert.deepEqual(f.call('thread.open', { thread, session: named }), { opened: thread });
assert.ok(f.b.request(cap, { verb: 'role.claim' }));
});
test('a queued clear can retire and cancel after archive but cannot start its successor', t => {
const f = setup(t), p = project(f);
const thread = f.call('thread.create', { project: p, name: 'queued-clear', instance: 'continuity' }).id;
const se = f.call('session.list', { thread }).find(s => s.default);
f.call('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' });
f.call('project.archive', { project: p });
assert.equal(f.code('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }), 'project-archived');
const where = { business: 'demo', thread, op: 'queued-clear' };
f.b.sessionClearExited({ ...where, run: null });
f.b.sessionClearRetire(where);
const pending = f.b.sessionClears('demo').find(s => s.op === where.op);
assert.ok(pending.successor);
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'successor', harness: 'pi', thread, session: pending.successor }), { code: 'project-archived' });
assert.ok(f.call('session.cancel', { thread, op: where.op, confirm: true }));
assert.ok(f.call('session.list', { thread }).length);
});
test('archive preserves an accepted run result, release/end and business-wide launch admission', t => {
const f = setup(t), p = project(f);
const thread = f.call('thread.create', { project: p, name: 'accepted', instance: 'continuity' }).id;
const session = f.call('session.list', { thread }).find(s => s.default).id;
f.caps.running = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'accepted', harness: 'pi', thread, session });
f.call('role.claim', {}, 'running');
f.call('project.archive', { project: p });
assert.ok(f.call('message.send', { to: 'human', body: 'Accepted work is complete.' }, 'running').id);
assert.equal(f.call('thread.messages', { thread }).at(-1).body, 'Accepted work is complete.');
assert.ok(f.call('role.release', {}, 'running'));
assert.ok(f.b.endLaunch({ business: 'demo', run: 'accepted', reason: 'done' }));
assert.ok(f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'business-live', harness: 'pi' }));
});
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
test('only the owner Console reads and writes', t => {
const root = mkdtempSync(join(tmpdir(), 'project-remote-bus-')), store = new Store(root), calls = [];
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: [], crossRole: [] } } },
projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, organization: (...args) => { calls.push(args); return { project: 'stack', revision: 'a'.repeat(64), remote: null, firstSaveAllowed: true }; } });
const owner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } });
const cli = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = broker.bindReader({ business: 'demo' }), tester = broker.bindTester({ business: 'demo' });
const get = { verb: 'project.remote.get.console', args: { project: 'stack' } };
assert.equal(broker.request(owner, get).project, 'stack');
for (const cap of [cli, reader, tester]) assert.throws(() => broker.request(cap, get), /human-required|read-only|tester-refused/);
assert.throws(() => broker.request(owner, { ...get, args: { project: 'stack', business: 'other' } }), /invalid-request/);
assert.equal(broker.request(owner, { verb: 'project.remote.set.console', args: { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' } }).project, 'stack');
assert.equal(calls.length, 2);
assert.throws(() => broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } }), /human-required/);
});
test('socket refuses tester, viewer, collaborator, agent and CLI human for both remote verbs', async t => {
const { serve } = await import('../src/server.mjs');
const { Client } = await import('../src/client.mjs');
const root = mkdtempSync(join(tmpdir(), 'remote-socket-')), store = new Store(root);
const access = { accessVersion: 1, revision: 1, users: [], grants: [
{ id: 'g-1', user: 'viewer', business: 'demo', projects: ['*'], instances: ['pm'], level: 'viewer' },
{ id: 'g-2', user: 'collab', business: 'demo', projects: ['*'], instances: ['pm'], level: 'collaborator' },
] };
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'pm', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } } }, projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, access: () => access, organization: () => { throw Error('unexpected writer'); } });
const caps = {
human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }),
viewer: broker.bindUser({ business: 'demo', user: 'viewer' }),
collaborator: broker.bindUser({ business: 'demo', user: 'collab' }),
agent: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'remote-test', harness: 'pi' }),
};
const path = join(root, 'bus.sock'), server = await serve({ broker, path });
t.after(async () => { await server.close(); store.close(); rmSync(root, { recursive: true, force: true }); });
for (const [who, cap] of Object.entries(caps)) for (const [verb, args] of [
['project.remote.get.console', { project: 'stack' }],
['project.remote.set.console', { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' }],
]) await assert.rejects(new Client({ path, cap }).call(verb, args), e => ['human-required', 'read-only', 'tester-refused', 'access-refused'].includes(e.code), `${who} ${verb}`);
});
+15
View File
@@ -49,6 +49,21 @@ const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const reply = (b, cap, to, body, extra = {}) =>
call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra });
test('send receipts derive request provenance only from a valid reply chain', (t) => {
const { b, human, coder, pm, sent } = setup(t);
assert.ok(sent.request, 'a human send creates the original request');
const relay = call(b, coder, 'message.send', { to: 'pm', in_reply_to: sent.id, body: 'Please handle this' });
assert.equal(relay.request, sent.request);
const onward = call(b, pm, 'message.send', { to: 'human', in_reply_to: relay.id, body: 'Handled' });
assert.equal(onward.request, sent.request);
const unlinked = call(b, pm, 'message.send', { to: 'coder', body: `request ${sent.request}` });
assert.equal(unlinked.request, null, 'body text does not confer provenance');
const wrongRecipient = call(b, pm, 'message.send', { to: 'human', in_reply_to: sent.id, body: 'Not the recipient' });
assert.equal(wrongRecipient.request, null, 'a sender cannot claim another role’s reply');
assert.throws(() => call(b, coder, 'message.send', { to: 'pm', body: 'forged', request: sent.request }), { code: 'invalid-request' });
assert.ok(call(b, human, 'message.send', { to: 'pm', body: 'Separate request' }).request);
});
test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => {
const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t);
call(b, coder, 'message.receive');
@@ -0,0 +1,143 @@
// Task 8a: shipped host and authority, real repositories/approval and tracker adapter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
import { git } from '../../project/tests/prd-fixture.mjs';
import { requestFixture } from './request-fixture.mjs';
async function picker(t, text = 'Build a product.') {
const f = await scopedFixture(t), product = await f.product('timer');
const request = f.call('human', 'message.send', { to: 'pm', body: text }).request;
let state = await f.call('pm', 'request.open', { request });
state = await f.call('pm', 'request.propose', { request, revision: state.revision, op: 'existing-route',
source_project: 'stack', project: 'unused', name: 'Unused', tracker_project: f.child });
const choose = async (id, choice) => {
const preview = await f.call('console', 'decision.preview.console', { id });
await f.call('console', 'decision.resolve.console', { id, choice, digest: preview.digest });
return f.bus.requestLifecycle.resume('demo', request);
};
state = await choose(state.selection.decision, 'existing');
const rows = await f.call('console', 'request.projects.console', { request });
const pick = () => f.call('console', 'request.attach.console', { request, revision: state.revision, project: 'timer', op: 'pick' });
const propose = picked => f.call('pm', 'request.propose', { request, revision: picked.revision, op: 'attach-plan',
source_project: 'timer', project: 'timer', name: 'timer', tracker_project: f.child });
return { ...f, product, request, state, rows, pick, propose, choose };
}
test('attach-contract: the shipped owner picker includes an approved mapped product and its mapping revision', async t => {
const f = await picker(t);
assert.equal((await f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer' })).id, f.product.snapshot.id);
const row = f.rows.find(row => row.project === 'timer');
assert.ok(row, 'Task 8 needs the real host inventory, not a test-injected listProjects');
assert.match(row.mapping_revision, /^[a-f0-9]{64}$/);
assert.ok(f.rows.every(row => Object.hasOwn(f.config.businesses.demo.projects, row.project) || row.project === 'timer'));
assert.ok(!f.rows.some(row => row.project === 'unused'));
});
test('attachment adopts the matching approved snapshot, keeps paths and creates no project or approval', async t => {
const f = await picker(t), file = join(f.f.dataRoot, 'projects/demo/timer');
const before = { head: git(file, 'rev-parse', 'HEAD'), projects: readdirSync(join(f.f.dataRoot, 'products/demo')) };
const picked = await f.pick(), proposed = await f.propose(picked);
assert.equal(proposed.selection.bindings[0].plan.existing, true);
assert.equal(proposed.selection.bindings[0].plan.records_root, file);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.phase, 'planning');
assert.equal(state.snapshot, f.product.snapshot.id);
assert.equal(state.candidate.records_commit, f.product.submission.candidate.records_commit);
assert.equal(state.mode, 'autonomous');
assert.equal(git(file, 'rev-parse', 'HEAD'), before.head);
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), before.projects);
const task = await f.create({ ...f.product.args, request: f.request, op: 'attached-task' });
assert.match(task.task_ref, /^vikunja:2\//);
assert.equal(f.posts(), 1);
const again = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(again.snapshot, state.snapshot);
assert.equal(again.revision, state.revision);
});
test('attachment with different intent enters discovery and never borrows the old snapshot', async t => {
const f = await picker(t, 'Add a keyboard shortcut to the timer.');
const picked = await f.pick(), proposed = await f.propose(picked);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.project, 'timer');
assert.equal(state.phase, 'discovery');
assert.equal(state.snapshot, null);
assert.equal(state.wait.code, 'request-discovery-unavailable');
assert.equal(f.posts(), 0);
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), ['timer']);
await assert.rejects(f.create({ ...f.product.args, request: f.request, op: 'borrow-old-snapshot' }), /requirement-snapshot-required/);
});
test('the Console picker retains its server mapping revision in the HTTP response', async () => {
const { requestHTTP } = await import('../../webui/src/request-http.mjs');
const { Readable } = await import('node:stream');
const req = Readable.from([]); req.method = 'GET'; req.headersDistinct = {};
const session = { name: 'jason', provider: 'fixture', iss: 'fixture', sub: 'owner' };
let body; const res = { writeHead() {}, end(value) { body = JSON.parse(value); } };
await requestHTTP(req, res, { path: '/api/requests/r1/projects', session, live: () => session, origin: 'https://console.test', log: () => {},
requestRead: async () => [{ project: 'timer', name: 'timer', mapping_revision: 'a'.repeat(64) }] });
assert.equal(body[0].mapping_revision, 'a'.repeat(64));
});
function changeMapping(f) {
const file = join(f.f.config, '..', 'businesses/demo.json');
const doc = JSON.parse(readFileSync(file, 'utf8'));
doc.projects.timer.remote = 'https://git.example/owner/changed';
writeFileSync(file, JSON.stringify(doc) + '\n');
}
test('a mapping change after the owner picker refuses the pick with no attachment', async t => {
const f = await picker(t);
changeMapping(f);
await assert.rejects(f.pick(), /request-existing-project-changed/);
assert.equal((await f.call('console', 'request.get.console', { request: f.request })).wait.code, 'request-existing-project');
});
test('a mapping change after the pick refuses preparation instead of silently rebinding', async t => {
const f = await picker(t), picked = await f.pick();
changeMapping(f);
const state = await f.propose(picked);
assert.equal(state.wait.code, 'request-existing-project-changed');
assert.equal(state.selection, null);
});
test('a mapping change after preparation cannot confirm the old attachment', async t => {
const f = await picker(t), proposed = await f.propose(await f.pick());
changeMapping(f);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.project, null);
assert.match(state.wait.code, /request-(organization|existing-project)-changed/);
});
test('an attachment read refusal stays visible and resumes after the existing records are repaired', async t => {
const f = await picker(t), proposed = await f.propose(await f.pick());
const metadata = join(f.f.dataRoot, 'projects/demo/timer/.git');
renameSync(metadata, metadata + '-held');
const waiting = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(waiting.phase, 'discovery');
assert.equal(waiting.snapshot, null);
assert.ok(waiting.wait.code);
renameSync(metadata + '-held', metadata);
const resumed = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(resumed.phase, 'planning');
assert.equal(resumed.snapshot, f.product.snapshot.id);
});
test('owner answers on an assistant-origin request do not put a PM message in the assistant session', async t => {
const f = await requestFixture(t, 'guided', null, true);
let s = await f.call('pm', 'request.get', { request: f.request });
s = await f.call('pm', 'request.question', { request: f.request, revision: s.revision, op: 'thread-question', text: 'Offline?' });
const answered = await f.call('console', 'request.answer.console', { request: f.request, revision: s.revision, question: s.question.id, op: 'thread-answer', text: 'Yes.' });
assert.ok(answered.question.answer);
assert.equal(f.store.get("SELECT count(*) n FROM message_threads mt JOIN messages m ON m.id=mt.message WHERE mt.session=? AND m.to_role='pm' AND m.from_role='human'", s.session).n, 0);
});
test('cancel during guided PRD approval withdraws its exact pending decision', async t => {
const f = await requestFixture(t, 'guided', null, true), submitted = await f.submit();
const s = await f.call('pm', 'request.get', { request: f.request });
const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: s.revision, op: 'cancel-prd' });
assert.equal(cancelled.phase, 'cancelled');
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", submitted.decision.id).n, 1);
});
@@ -0,0 +1,868 @@
// Lifecycle Task 2 (#797 / Gitea #1839): a confirmed new-project selection becomes a usable project.
// Real startBroker over a real organization file, a real git factory repo and a fixture data root.
// The test barrier stands in for a kill: it throws after a step's effect and before its receipt.
import test from 'node:test';
import assert from 'node:assert/strict';
import cp, { execFileSync } from 'node:child_process';
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'Factory', GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Factory', GIT_COMMITTER_EMAIL: '[email protected]' };
const git = (cwd, ...args) => execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8' }).trim();
const PROJECT_FILE = { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'git.workingBranch': 'main' } };
// The factory: the declared `stack` project is a git repository with a project file.
function factory(root) {
const dir = join(root, 'project');
mkdirSync(join(dir, '.mosaic'), { recursive: true });
writeFileSync(join(dir, '.mosaic/project.json'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
writeFileSync(join(dir, 'README.md'), 'factory\n');
git(dir, 'init', '-q', '-b', 'main'); git(dir, 'add', '-A'); git(dir, 'commit', '-q', '-m', 'factory');
return dir;
}
// `defaultServices` boots with nothing injected, so the host builds its own preparation and admission; `withTrackers` forwards the
// boot config's tracker settings as process.mjs does (the adapter itself is not built).
async function setup(t, { barrier, configureFactory = () => {}, beforePropose = () => {}, defaultServices = false, withTrackers = defaultServices } = {}) {
const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo'), source = factory(root);
configureFactory({ root, f, source });
const hooks = { barrier };
const system = loadSystem({ env: f.env });
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
const plans = (dataRoot, ready = () => true) => ({
preparePlans: (state, args, from) => Promise.all(['autonomous', 'guided'].map(mode => prepareBootstrap({
business: writer.read('demo').document, request: state.request, sourceProject: args.source_project, project: args.project, name: args.name,
mode, dataRoot, organizationRevision: writer.read('demo').revision, tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' },
intent: { text: from.text, source_message: from.source_message } }))),
admitPlan: plan => plan.organization_revision === writer.read('demo').revision && ready() ? { ready: true } : { ready: false, code: 'organization-stale' },
barrier: step => hooks.barrier?.(step),
});
const boot = async () => {
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) })));
const bus = await startBroker({ ...config, ...(withTrackers ? { trackers } : {}), requestServices: defaultServices ? {} : plans(f.dataRoot) });
t.after(() => bus.close().catch(() => {}));
return { bus, config, trackers, warnings };
};
const first = await boot();
const caps = { human: first.bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
for (const role of ['pm', 'cto', 'coder']) {
caps[role] = first.bus.broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
first.bus.broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args });
const sent = call('human', 'message.send', { to: 'pm', body: INTENT });
const opened = await call('pm', 'request.open', { request: sent.request });
// A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back.
let proposed = null, proposeError = null;
beforePropose({ root, f, source });
try {
proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack',
project: 'timer', name: 'Timer', tracker_project: 2 });
} catch (e) { proposeError = e; }
const choose = async (choice = 'guided') => {
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
return first.bus.requestLifecycle.resume('demo', sent.request);
};
const paths = { code: join(f.dataRoot, 'products/demo/timer/code'), records: join(f.dataRoot, 'projects/demo/timer'), product: join(f.dataRoot, 'products/demo/timer') };
const changes = () => { try { return readFileSync(writer.logFile('demo'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); } catch { return []; } };
const additions = () => changes().filter(c => c.state === 'committed' && c.detail?.verb === 'project.add');
return { root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, proposeError, choose, paths, changes, additions,
rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) };
}
test('bootstrap rechecks archive before reservation after asynchronous preparation', async t => {
const f = await setup(t, { barrier: step => {
if (step === 'begin') {
const source = f.call('human', 'project.create', { name: 'Factory', repo: 'stack' });
f.call('human', 'project.archive', { project: source.id });
}
} });
const state = await f.choose();
assert.equal(state.wait.code, 'project-archived');
assert.equal(existsSync(f.paths.product), false);
assert.equal(f.additions().length, 0);
});
test('archive during bootstrap blocks activation and preserves earlier effect receipts', async t => {
const f = await setup(t, { barrier: step => {
if (step === 'activate') {
const project = f.rows().find(p => p.repo === 'timer');
f.call('human', 'project.archive', { project: project.id });
}
} });
const state = await f.choose();
assert.equal(state.wait.code, 'project-archived');
assert.equal(state.phase, 'bootstrap');
assert.equal(f.additions().length, 1, 'already committed mapping remains');
assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).code, 'project-archived');
assert.equal(JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state, 'reserved');
});
test('bootstrap_connects_all_resources', async t => {
const f = await setup(t), factoryHead = git(f.source, 'rev-parse', 'HEAD'), orgBefore = f.writer.read('demo');
const done = await f.choose('guided');
assert.equal(done.phase, 'discovery'); assert.equal(done.project, 'timer'); assert.equal(done.mode, 'guided');
assert.equal(done.wait.code, 'request-discovery-unavailable');
// The organization file: exactly one added mapping, everything else byte-for-byte the same document.
const org = f.writer.read('demo');
assert.deepEqual(org.document.projects.timer, { root: f.paths.code, repo: f.paths.records });
assert.deepEqual(org.document.projects.stack, orgBefore.document.projects.stack);
const { projects: a, ...restNow } = org.document, { projects: b, ...restBefore } = orgBefore.document;
assert.deepEqual(restNow, restBefore);
assert.equal(f.additions().length, 1);
assert.equal(f.additions()[0].detail.plan_digest, done.plan_digest);
assert.equal(f.additions()[0].after, org.revision);
// The code checkout: the factory commit plus one seed commit that writes only the project file.
assert.equal(git(f.paths.code, 'rev-parse', 'HEAD~1'), factoryHead);
assert.equal(git(f.paths.code, 'diff', '--name-status', 'HEAD~1', 'HEAD'), 'M\t.mosaic/project.json');
assert.deepEqual(JSON.parse(readFileSync(join(f.paths.code, '.mosaic/project.json'), 'utf8')), { projectVersion: 1, id: 'timer', vars: { 'git.workingBranch': 'main' } });
assert.equal(git(f.paths.code, 'remote'), '');
assert.equal(git(f.paths.code, 'status', '--porcelain'), '');
// The records repository: layout commit, then the original intent as the owner's edit.
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '2');
assert.equal(git(f.paths.records, 'log', '-1', '--format=%an|%s'), 'jason|Edit spec/north-star.md');
assert.equal(git(f.paths.records, 'rev-parse', 'HEAD'), done.spec_commit);
assert.equal(readFileSync(join(f.paths.records, 'spec/north-star.md'), 'utf8'), INTENT);
// The chat association and the working repository.
const chat = f.rows().find(p => p.name === 'Timer');
assert.equal(chat.repo, 'timer'); assert.equal(chat.id, done.chat_project);
assert.equal(f.rows().filter(p => p.name === 'Timer').length, 1);
assert.equal(git(workingRepo(f.f.dataRoot, 'demo', 'timer'), 'rev-parse', 'refs/heads/main'), git(f.paths.code, 'rev-parse', 'HEAD'));
// The single tracker root stays; the selected board lives in the immutable plan and the admission projection.
const plan = done.selection.bindings.find(x => x.digest === done.plan_digest).plan;
assert.deepEqual(plan.tracker, { root: 1, project: 2 });
assert.deepEqual(await f.first.bus.admission({ business: 'demo', project: 'timer' }), { ready: true, revision: org.revision, code: null });
// The factory is untouched and the broker serves the new project without a restart.
assert.equal(git(f.source, 'rev-parse', 'HEAD'), factoryHead); assert.equal(git(f.source, 'status', '--porcelain'), '');
assert.ok(f.call('human', 'project.create', { name: 'Side', repo: 'timer' }).id);
// The next launch: a fresh boot lists the project, verifies it and still names the one tracker project.
await f.first.bus.close();
const next = await f.boot();
assert.deepEqual(next.config.businesses.demo.projects.timer, { root: f.paths.code, repo: f.paths.records });
assert.equal(next.trackers.demo.project, 1);
assert.deepEqual(next.warnings, []);
assert.equal(loadBusiness('demo', { dir: join(f.root, 'config'), rolesDir: f.f.roles }).projects.timer.root, f.paths.code);
});
test('a_product_is_not_admitted_until_every_step_passes', async t => {
let seen;
const f = await setup(t, { barrier: async step => { if (step === 'intent') seen = { admission: await f.first.bus.admission({ business: 'demo', project: 'timer' }) }; } });
await f.choose();
assert.equal(seen.admission.ready, false); assert.equal(typeof seen.admission.code, 'string');
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
assert.deepEqual(await f.first.bus.admission({ business: 'demo', project: 'stack' }), { ready: true, revision: f.writer.read('demo').revision, code: null });
assert.deepEqual(await f.first.bus.admission({ business: 'demo', project: 'absent' }), { ready: false, revision: null, code: 'project-unknown' });
assert.deepEqual(await f.first.bus.admission({ business: 'other', project: 'timer' }), { ready: false, revision: null, code: 'project-unknown' });
});
// Lifecycle #800: an association or organization change can land after the last awaited read.
function staleCase(name, change, { keep } = {}) {
test(name, async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'begin') { armed = false; change(f); } } });
const orgBefore = readFileSync(join(f.root, 'config/businesses/demo.json'));
const out = await f.choose();
assert.equal(out.wait.code, 'request-plan-stale'); assert.equal(out.phase, 'selection'); assert.equal(out.project, null);
assert.equal(out.plan_digest, null); assert.equal(out.selection, null);
assert.equal(f.additions().length, 0);
assert.equal(existsSync(f.paths.records), false);
assert.equal(existsSync(join(f.paths.code, '.mosaic')), false);
keep?.(f, orgBefore);
// Nothing is retained that a fresh preview could trip over, and a re-run does not resume the old plan.
assert.equal((await f.first.bus.requestLifecycle.resume('demo', f.request)).wait.code, 'request-plan-stale');
});
}
staleCase('an_organization_change_after_confirmation_waits_without_effect', f => {
const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', next, { revision: now.revision, actor: 'jason' });
}, { keep: f => assert.equal(existsSync(f.paths.product), false) });
staleCase('a_changed_source_commit_waits_without_effect', f => {
writeFileSync(join(f.source, 'README.md'), 'changed\n'); git(f.source, 'commit', '-qam', 'moved');
}, { keep: f => assert.equal(existsSync(f.paths.product), false) });
staleCase('preexisting_unrelated_data_is_left_alone_and_the_plan_waits', f => {
mkdirSync(f.paths.code, { recursive: true }); writeFileSync(join(f.paths.code, 'keep.txt'), 'mine\n');
}, { keep: f => assert.equal(readFileSync(join(f.paths.code, 'keep.txt'), 'utf8'), 'mine\n') });
staleCase('a_symlink_ancestor_is_refused_and_its_target_is_untouched', f => {
const outside = join(f.root, 'outside'); mkdirSync(outside); symlinkSync(outside, join(f.f.dataRoot, 'products'));
}, { keep: f => { assert.deepEqual(readdirSync(join(f.root, 'outside')), []); assert.ok(lstatSync(join(f.f.dataRoot, 'products')).isSymbolicLink()); } });
staleCase('a_path_overlap_with_a_declared_project_waits_without_effect', f => {
const now = f.writer.read('demo'), next = structuredClone(now.document);
next.projects.stack = { ...next.projects.stack, repo: join(f.paths.code, 'inner') };
writeFileSync(join(f.root, 'config/businesses/demo.json'), JSON.stringify(next, null, 2) + '\n');
}, { keep: f => assert.equal(existsSync(f.paths.product), false) });
// Kill (the barrier throws after the effect and before its receipt), reopen, recover exactly once.
for (const step of ['mapping', 'intent']) {
test(`kill_after_${step}_then_reopen_recovers_the_same_project_exactly_once`, async t => {
let armed = true;
const f = await setup(t, { barrier: s => { if (armed && s === step) { armed = false; throw new Error('killed'); } } });
const factoryHead = git(f.source, 'rev-parse', 'HEAD');
const stopped = await f.choose();
assert.equal(stopped.phase, 'bootstrap'); assert.equal(stopped.wait.code, 'bootstrap-uncertain');
assert.equal(f.writer.read('demo').document.projects.timer !== undefined, true);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
const mapped = f.writer.read('demo').revision;
await f.first.bus.close();
const next = await f.boot();
const human = next.bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const done = await next.bus.broker.request(human, { verb: 'request.get', args: { request: f.request } });
assert.equal(done.phase, 'discovery');
if (step === 'mapping') assert.equal(f.writer.read('demo').revision, mapped, 'recovery adds no second mapping');
assert.equal(f.additions().length, 1);
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '2');
assert.equal(git(f.paths.records, 'rev-parse', 'HEAD'), done.spec_commit);
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '2');
assert.equal(git(f.paths.code, 'rev-parse', 'HEAD~1'), factoryHead);
assert.equal(git(f.source, 'rev-parse', 'HEAD'), factoryHead); assert.equal(git(f.source, 'status', '--porcelain'), '');
assert.equal(next.bus.broker.request(human, { verb: 'project.list', args: {} }).filter(p => p.name === 'Timer').length, 1);
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
assert.equal(next.warnings.length, 0);
});
}
test('ordinary_organization_writers_still_refuse_every_project_change', async t => {
const f = await setup(t), now = f.writer.read('demo'), next = structuredClone(now.document);
next.projects.timer = { root: f.paths.code, repo: f.paths.records };
assert.throws(() => f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }), { code: 'organization-projects-protected' });
assert.equal(f.writer.read('demo').revision, now.revision);
});
// ---- Fix round for review r1 (#797 Task 2): C1, C2, I1 ---------------------------------------------------------
const external = (root, name = 'external') => { const dir = join(root, name); mkdirSync(dir); return dir; };
const stateOf = async (f, bus = f.first.bus) => bus.broker.request(bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'request.get', args: { request: f.request } });
// C1. A committed `.mosaic` link is refused where the plan is prepared, so the owner never sees a plan that would write through it.
test('c1_a_committed_mosaic_directory_link_is_refused_and_the_external_file_keeps_its_bytes', async t => {
let victim, before;
const f = await setup(t, { configureFactory: ({ root, source }) => {
const outside = external(root);
victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n';
writeFileSync(victim, before);
rmSync(join(source, '.mosaic'), { recursive: true });
symlinkSync(outside, join(source, '.mosaic'));
git(source, 'add', '-A'); git(source, 'commit', '-qm', 'linked project metadata');
} });
assert.equal(f.proposeError?.code, 'request-project-refused'); assert.equal(f.proposed, null);
assert.equal(readFileSync(victim, 'utf8'), before);
assert.equal(existsSync(f.paths.product), false); assert.equal(f.additions().length, 0);
});
// The config loader refuses a linked project file at boot, so this link is committed after boot: preparation is the guard.
test('c1_a_committed_project_file_link_is_refused_at_preparation', async t => {
let victim, before;
const f = await setup(t, { beforePropose: ({ root, source }) => {
const outside = external(root);
victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n';
writeFileSync(victim, before);
rmSync(join(source, '.mosaic/project.json'));
symlinkSync(victim, join(source, '.mosaic/project.json'));
git(source, 'add', '-A'); git(source, 'commit', '-qm', 'linked project file');
} });
assert.equal(f.proposeError?.code, 'request-project-refused'); assert.equal(f.proposed, null);
assert.equal(readFileSync(victim, 'utf8'), before);
assert.equal(existsSync(f.paths.product), false);
});
// The executor does not trust the plan: a link that appears in the checkout is refused before anything is written through it.
for (const [what, link] of [
['mosaic_directory', (code, outside) => { rmSync(join(code, '.mosaic'), { recursive: true }); symlinkSync(outside, join(code, '.mosaic')); }],
['project_file', (code, outside) => { rmSync(join(code, '.mosaic/project.json')); symlinkSync(join(outside, 'project.json'), join(code, '.mosaic/project.json')); }],
]) {
test(`c1_a_${what}_link_in_the_checkout_is_refused_and_the_external_file_keeps_its_bytes`, async t => {
let victim, before, f;
f = await setup(t, { barrier: step => {
if (step !== 'cloned') return;
const outside = external(f.root);
victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n';
writeFileSync(victim, before);
link(f.paths.code, outside);
} });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(readFileSync(victim, 'utf8'), before);
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1', 'no seed commit was made through the link');
assert.equal(f.additions().length, 0);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
}
test('c1_a_marker_temp_link_is_never_followed_and_the_product_is_not_admitted', async t => {
let victim, f;
f = await setup(t, { barrier: step => {
if (step !== 'activate') return;
victim = join(external(f.root), 'victim.txt'); writeFileSync(victim, 'mine\n');
symlinkSync(victim, join(f.paths.product, 'bootstrap.json.tmp'));
} });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(readFileSync(victim, 'utf8'), 'mine\n');
assert.equal(JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state, 'reserved');
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
test('c1_a_seed_temp_link_is_never_followed_and_the_seed_is_not_published', async t => {
let victim, f;
f = await setup(t, { barrier: step => {
if (step !== 'reserve') return;
victim = join(external(f.root), 'victim.txt'); writeFileSync(victim, 'mine\n');
symlinkSync(victim, join(f.paths.product, 'project.json.seed'));
} });
const out = await f.choose();
assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(readFileSync(victim, 'utf8'), 'mine\n');
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1'); assert.equal(f.additions().length, 0);
});
// C2. A path nobody claimed is never removed.
test('c2_unrelated_code_tmp_contents_survive_and_the_step_is_uncertain', async t => {
let f;
f = await setup(t, { barrier: step => {
if (step !== 'reserve') return;
mkdirSync(join(f.paths.product, 'code.tmp'));
writeFileSync(join(f.paths.product, 'code.tmp', 'keep.txt'), 'unrelated\n');
} });
const out = await f.choose();
assert.equal(readFileSync(join(f.paths.product, 'code.tmp', 'keep.txt'), 'utf8'), 'unrelated\n');
assert.deepEqual(readdirSync(join(f.paths.product, 'code.tmp')), ['keep.txt']);
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(existsSync(f.paths.code), false); assert.equal(f.additions().length, 0);
const op = `bootstrap:${out.plan_digest}:seed`;
assert.equal(f.first.bus.requestJournal?.effects?.('demo', f.request).filter(r => r.op === op).at(-1)?.stage ?? 'uncertain', 'uncertain');
});
// Ownership comes from the claim this plan wrote, never from how a directory looks.
test('c2_an_unclaimed_empty_code_tmp_is_kept_and_not_adopted', async t => {
let f;
f = await setup(t, { barrier: step => { if (step === 'reserve') mkdirSync(join(f.paths.product, 'code.tmp')); } });
const out = await f.choose();
assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.deepEqual(readdirSync(join(f.paths.product, 'code.tmp')), []); assert.equal(existsSync(f.paths.code), false);
assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), false); assert.equal(f.additions().length, 0);
});
test('c2_an_unclaimed_clone_of_the_same_commit_is_kept_and_not_adopted', async t => {
let f;
f = await setup(t, { barrier: step => { if (step === 'reserve') git(f.root, 'clone', '-q', f.source, join(f.paths.product, 'code.tmp')); } });
const out = await f.choose();
assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(existsSync(join(f.paths.product, 'code.tmp/.git')), true); assert.equal(existsSync(f.paths.code), false);
assert.equal(f.additions().length, 0);
});
test('c2_a_claimed_clone_left_by_a_kill_is_adopted_exactly_once_on_retry', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'clone') { armed = false; throw new Error('killed'); } } });
const stopped = await f.choose();
assert.equal(stopped.wait.code, 'bootstrap-uncertain');
assert.equal(existsSync(join(f.paths.product, 'code.tmp/.git')), true); assert.equal(existsSync(f.paths.code), false);
assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), true);
await f.first.bus.close();
const next = await f.boot();
const done = await stateOf(f, next.bus);
assert.equal(done.phase, 'discovery');
assert.equal(existsSync(join(f.paths.product, 'code.tmp')), false); assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), false);
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '2'); assert.equal(f.additions().length, 1);
});
test('c2_a_claimed_clone_with_foreign_content_is_kept_and_reported_uncertain', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'clone') { armed = false; throw new Error('killed'); } } });
await f.choose(); await f.first.bus.close();
writeFileSync(join(f.paths.product, 'code.tmp/extra.txt'), 'foreign\n');
const next = await f.boot();
const out = await stateOf(f, next.bus);
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(readFileSync(join(f.paths.product, 'code.tmp/extra.txt'), 'utf8'), 'foreign\n');
assert.equal(existsSync(f.paths.code), false); assert.equal(f.additions().length, 0);
});
// I1. The freshness check holds before every effect, not only the first one.
test('i1_an_organization_change_after_reservation_waits_and_keeps_the_reserved_paths', async t => {
let armed = true, f;
f = await setup(t, { barrier: step => {
if (armed && step === 'reserve') {
armed = false;
const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', next, { revision: now.revision, actor: 'jason' });
}
} });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale'); assert.equal(out.project, 'timer');
assert.equal(existsSync(join(f.paths.product, 'bootstrap.json')), true, 'completed evidence is kept');
assert.equal(existsSync(f.paths.code), false); assert.equal(existsSync(join(f.paths.product, 'code.tmp')), false);
assert.equal(f.additions().length, 0); assert.deepEqual(readdirSync(f.paths.records), []);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
assert.equal((await f.first.bus.requestLifecycle.resume('demo', f.request)).wait.code, 'request-plan-stale', 'a re-run performs no effect either');
assert.equal(existsSync(f.paths.code), false);
});
test('i1_an_organization_change_after_the_mapping_stops_the_remaining_effects_across_a_restart', async t => {
let armed = true, f;
f = await setup(t, { barrier: step => {
if (armed && step === 'mapping') {
armed = false;
const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', next, { revision: now.revision, actor: 'jason' });
}
} });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale');
assert.equal(f.additions().length, 1);
assert.deepEqual(readdirSync(f.paths.records), [], 'no records were written on the moved organization');
await f.first.bus.close();
const next = await f.boot();
const again = await stateOf(f, next.bus);
assert.equal(again.phase, 'bootstrap'); assert.equal(again.wait.code, 'request-plan-stale');
assert.deepEqual(readdirSync(f.paths.records), []); assert.equal(f.additions().length, 1);
assert.equal(f.writer.read('demo').document.projects.timer !== undefined, true);
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
// I2. A product is classified by its layout, so deleting the marker cannot turn an unfinished one into an ordinary project.
test('i2_a_missing_marker_does_not_admit_an_unfinished_product_after_reopen', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'mapping') { armed = false; throw new Error('killed'); } } });
const stopped = await f.choose();
assert.equal(stopped.wait.code, 'bootstrap-uncertain'); assert.equal(f.additions().length, 1);
await f.first.bus.close();
rmSync(join(f.paths.product, 'bootstrap.json'));
const entry = f.writer.read('demo').document.projects.timer;
assert.equal(managedState({ dataRoot: f.f.dataRoot, business: 'demo', project: 'timer', entry }), 'reserved');
const next = await f.boot();
const admitted = await next.bus.admission({ business: 'demo', project: 'timer' });
assert.equal(admitted.ready, false); assert.equal(admitted.code, 'project-pending');
assert.equal(existsSync(join(f.paths.product, 'bootstrap.json')), false, 'recovery never rewrites a marker it did not write');
assert.equal((await stateOf(f, next.bus)).phase, 'bootstrap');
assert.equal(next.warnings.length, 0);
});
// I3. Nothing is injected: the host builds its own preparation and admission from the organization, the source
// checkout, the tracker settings it booted with and the staff the organization names.
test('i3_a_normal_boot_reaches_a_bootstrap_selection_and_bootstraps_the_project', async t => {
const f = await setup(t, { defaultServices: true });
assert.equal(f.proposeError, null);
assert.equal(f.proposed.phase, 'selection'); assert.equal(f.proposed.wait.kind, 'decision');
const [auto, guided] = f.proposed.selection.bindings.map(b => b.plan);
assert.deepEqual([auto.mode, guided.mode], ['autonomous', 'guided']);
assert.equal(auto.source.commit, git(f.source, 'rev-parse', 'HEAD'), 'the displayed commit is the source checkout\'s');
assert.equal(auto.organization_revision, f.writer.read('demo').revision);
assert.deepEqual(auto.tracker, { root: 1, project: 2 });
assert.deepEqual(auto.staff, { pm: 'pm', technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' });
assert.deepEqual([auto.code_root, auto.records_root], [f.paths.code, f.paths.records]);
assert.equal(auto.intent.text, INTENT);
const out = await f.choose();
assert.equal(out.phase, 'discovery'); assert.equal(f.additions().length, 1);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
});
test('i3_a_missing_tracker_root_is_a_named_setup_wait_and_creates_nothing', async t => {
const f = await setup(t, { defaultServices: true, withTrackers: false });
assert.equal(f.proposeError, null);
assert.equal(f.proposed.phase, 'selection'); assert.equal(f.proposed.wait.code, 'request-tracker-unavailable');
assert.equal(f.proposed.selection ?? null, null, 'no plan and no decision were made up');
assert.equal(existsSync(f.paths.product), false); assert.equal(f.additions().length, 0);
});
test('i3_a_staff_the_organization_does_not_name_is_a_named_setup_wait', async t => {
const f = await setup(t, { defaultServices: true, configureFactory: ({ root }) => {
const file = join(root, 'config/businesses/demo.json'), doc = JSON.parse(readFileSync(file, 'utf8'));
doc.launch.instances = ['coder']; writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
} });
assert.equal(f.proposeError, null);
assert.equal(f.proposed.wait.code, 'request-staff-unavailable'); assert.equal(f.proposed.selection ?? null, null);
assert.equal(existsSync(f.paths.product), false);
});
test('i3_host_admission_refuses_a_plan_whose_world_has_moved_with_a_named_code', async t => {
const f = await setup(t, { defaultServices: true }), plan = f.proposed.selection.bindings[0].plan;
const host = (over = {}) => hostPreparation({ dataRoot: f.f.dataRoot, organization: f.writer, trackers: { demo: { project: 1 } }, ...over });
assert.deepEqual(host().admitPlan(plan), { ready: true });
assert.deepEqual(host({ trackers: null }).admitPlan(plan), { ready: false, code: 'request-tracker-unavailable' });
assert.deepEqual(host({ trackers: { demo: { project: 7 } } }).admitPlan(plan), { ready: false, code: 'request-tracker-unavailable' });
assert.deepEqual(host({ organization: { read: () => { throw new Error('gone'); } } }).admitPlan(plan), { ready: false, code: 'request-organization-unavailable' });
assert.deepEqual(host().admitPlan({ ...plan, staff: { ...plan.staff, coder: 'reviewer' } }), { ready: false, code: 'request-staff-unavailable' });
const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', next, { revision: now.revision, actor: 'jason' });
assert.deepEqual(host().admitPlan(plan), { ready: false, code: 'request-organization-changed' });
assert.equal(staffFor(now.document, 'cto'), null, 'the PM cannot also be the technical reviewer');
assert.equal(staffFor({ ...now.document, launch: { ...now.document.launch, instances: ['coder', 'reviewer', 'reviewer'] } }, 'pm'), null);
});
// ---- Fix round for review r2 (#802): N1, I1, I2 -------------------------------------------------------------------
// N1. The working repository is confined to the data root like every other bootstrap path.
for (const [what, plant, kind] of [
['the_working_directory_is_a_link', (f, outside) => { symlinkSync(outside, join(f.dataRoot, 'working')); return join(f.dataRoot, 'working'); }, 'link'],
['the_business_directory_under_working_is_a_link', (f, outside) => {
mkdirSync(join(f.dataRoot, 'working')); symlinkSync(outside, join(f.dataRoot, 'working', 'demo')); return join(f.dataRoot, 'working', 'demo');
}, 'link'],
['the_working_directory_is_a_file', f => { writeFileSync(join(f.dataRoot, 'working'), 'not a directory\n'); return join(f.dataRoot, 'working'); }, 'file'],
]) {
test(`n1_${what}_so_nothing_is_written_through_it_and_the_product_is_not_admitted`, async t => {
let outside, planted;
const f = await setup(t, { configureFactory: ({ root, f }) => { outside = external(root, 'external-working'); planted = plant(f, outside); } });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.deepEqual(readdirSync(outside), [], 'nothing was made behind the link');
assert.equal(kind === 'link' ? lstatSync(planted).isSymbolicLink() : lstatSync(planted).isFile(), true, 'what was planted is left as it was');
assert.equal(f.additions().length, 0);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
}
test('n1_a_working_repository_already_there_on_the_first_attempt_is_kept_and_not_adopted', async t => {
const f = await setup(t, { configureFactory: ({ f }) => {
const repo = workingRepo(f.dataRoot, 'demo', 'timer'); mkdirSync(repo, { recursive: true }); writeFileSync(join(repo, 'keep.txt'), 'mine\n');
} });
const out = await f.choose();
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer');
assert.deepEqual(readdirSync(repo), ['keep.txt']); assert.equal(readFileSync(join(repo, 'keep.txt'), 'utf8'), 'mine\n');
assert.equal(f.additions().length, 0);
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
// The plan's own repository, left by a kill after the step's effect, is adopted on retry and the project completes.
test('n1_a_working_repository_left_by_a_kill_is_adopted_on_retry_and_the_project_completes', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
const stopped = await f.choose();
assert.equal(stopped.wait.code, 'bootstrap-uncertain');
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer');
assert.equal(git(repo, 'rev-parse', 'refs/heads/main'), git(f.paths.code, 'rev-parse', 'HEAD'));
await f.first.bus.close();
const next = await f.boot();
const done = await stateOf(f, next.bus);
assert.equal(done.phase, 'discovery'); assert.equal(f.additions().length, 1);
assert.equal(git(repo, 'rev-parse', 'refs/heads/main'), git(f.paths.code, 'rev-parse', 'HEAD'));
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
});
test('n1_a_working_repository_with_a_foreign_ref_is_kept_and_not_adopted_on_retry', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
await f.choose(); await f.first.bus.close();
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer'), head = git(repo, 'rev-parse', 'refs/heads/main');
git(repo, 'update-ref', 'refs/heads/foreign', head);
const next = await f.boot();
const out = await stateOf(f, next.bus);
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(git(repo, 'rev-parse', 'refs/heads/foreign'), head, 'the foreign ref is kept');
assert.equal(f.additions().length, 0);
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
// I1. A protected write happens only while the plan is still fresh, with the check and the start of the write in one
// synchronous run. These interleavings land an ordinary organization write after the step's last asynchronous read and
// before its write, which is exactly where a bare check-then-await let a stale plan write.
const moveOrganization = f => {
const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', next, { revision: now.revision, actor: 'jason' });
};
// #802 C1: a refused foreign repository never becomes this attempt's on retry.
for (const linked of [false, true]) {
test(`c1_foreign_working_repo_stays_refused_on_retry_${linked ? 'linked_objects' : 'empty_refs'}`, async t => {
let outside, repo;
const f = await setup(t, { configureFactory: ({ root, f }) => {
outside = external(root, 'external-objects');
repo = workingRepo(f.dataRoot, 'demo', 'timer'); mkdirSync(repo, { recursive: true });
git(repo, 'init', '-q', '--bare', '--template=');
if (linked) { rmSync(join(repo, 'objects'), { recursive: true }); symlinkSync(outside, join(repo, 'objects')); }
} });
assert.equal((await f.choose()).wait.code, 'bootstrap-uncertain');
assert.deepEqual(readdirSync(outside), []);
// Even replacing the foreign repository with matching refs does not undo its refusal.
if (!linked) git(repo, 'fetch', '-q', f.paths.code, 'HEAD:refs/heads/main', 'HEAD:refs/heads/root');
for (let retry = 0; retry < 2; retry++) {
const again = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(again.phase, 'bootstrap'); assert.equal(again.wait.code, 'bootstrap-uncertain');
assert.deepEqual(readdirSync(outside), []); assert.equal(f.additions().length, 0);
assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).ready, false);
}
});
}
for (const refs of ['empty', 'matching']) {
test(`c1_retry_refuses_a_replacement_working_repo_without_ownership_${refs}`, async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
assert.equal((await f.choose()).wait.code, 'bootstrap-uncertain');
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer');
rmSync(repo, { recursive: true }); mkdirSync(repo);
git(repo, 'init', '-q', '--bare', '--template=');
if (refs === 'matching') git(repo, 'fetch', '-q', f.paths.code, 'HEAD:refs/heads/main', 'HEAD:refs/heads/root');
const before = git(repo, 'for-each-ref', '--format=%(refname) %(objectname)');
const again = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(again.phase, 'bootstrap'); assert.equal(again.wait.code, 'bootstrap-uncertain');
assert.equal(git(repo, 'for-each-ref', '--format=%(refname) %(objectname)'), before);
assert.equal(f.additions().length, 0);
});
}
test('c1_retry_checks_internal_links_before_inspecting_working_refs', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
assert.equal((await f.choose()).wait.code, 'bootstrap-uncertain');
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer'), outside = external(f.root);
symlinkSync(outside, join(repo, 'objects/info/linked'));
let children = 0;
const result = await withSpawnHook((binary, args) => {
if (binary === 'git' && args.includes(`--git-dir=${repo}`)) children++;
}, () => f.bus.requestLifecycle.resume('demo', f.request));
assert.equal(result.phase, 'bootstrap'); assert.equal(result.wait.code, 'bootstrap-uncertain');
assert.equal(children, 0); assert.deepEqual(readdirSync(outside), []);
});
test('c1_retry_rechecks_internal_links_before_each_working_git_child', async t => {
let armed = true;
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
await f.choose();
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer'), outside = external(f.root);
let children = 0;
const result = await withSpawnHook((binary, args) => {
if (binary === 'git' && args.includes(`--git-dir=${repo}`)) {
children++;
if (args.includes('for-each-ref')) queueMicrotask(() => symlinkSync(outside, join(repo, 'objects/info/linked')));
}
}, () => f.bus.requestLifecycle.resume('demo', f.request));
assert.equal(children, 1); assert.equal(result.wait.code, 'bootstrap-uncertain');
assert.deepEqual(readdirSync(outside), []);
});
// #802 C2: these hooks run real organization writes at the review's two scheduling points.
test('c2_intent_commit_holds_the_organization_lock_until_git_finishes', async t => {
const f = await setup(t), revision = () => f.writer.read('demo').revision;
let attempted = false, before, refusal;
const result = await withSpawnHook((binary, args) => {
if (!attempted && binary === 'git' && args.includes(f.paths.records) && args.includes('commit') && args.includes('Edit spec/north-star.md')) {
attempted = true; before = revision();
assert.equal(readFileSync(join(f.paths.records, 'spec/north-star.md'), 'utf8'), INTENT);
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '1');
try { moveOrganization(f); } catch (e) { refusal = e.code; }
}
}, () => f.choose());
assert.equal(attempted, true); assert.equal(refusal, 'organization-busy');
assert.equal(revision(), before); assert.equal(result.phase, 'discovery');
assert.equal(git(f.paths.records, 'log', '-1', '--format=%s'), 'Edit spec/north-star.md');
moveOrganization(f); assert.notEqual(revision(), before, 'the lock is released after the effect');
});
test('c2_records_init_holds_the_organization_lock_across_awaits', async t => {
const f = await setup(t), original = cp.spawn;
let attempted = false, refusal, before;
cp.spawn = function (binary, args, ...rest) {
const child = original.call(this, binary, args, ...rest);
if (!attempted && binary === 'git' && args.includes(f.paths.records) && args.includes('init')) {
attempted = true; before = f.writer.read('demo').revision;
queueMicrotask(() => { try { moveOrganization(f); } catch (e) { refusal = e.code; } });
}
return child;
};
syncBuiltinESMExports();
let result;
try { result = await f.choose(); } finally { cp.spawn = original; syncBuiltinESMExports(); }
assert.equal(attempted, true); assert.equal(refusal, 'organization-busy');
assert.equal(f.writer.read('demo').revision, before); assert.equal(result.phase, 'discovery');
assert.equal(git(f.paths.records, 'log', '--format=%s').split('\n')[1], 'Initialize project layout v1');
});
test('c2_intent_rollback_keeps_the_organization_lock_and_retry_completes', async t => {
const f = await setup(t);
let failed = false, refusal, before;
const result = await withSpawnHook((binary, args) => {
if (binary !== 'git' || !args.includes(f.paths.records)) return;
if (!failed && args.includes('commit') && args.includes('Edit spec/north-star.md')) {
failed = true; before = f.writer.read('demo').revision;
throw new Error('injected commit spawn failure');
}
if (failed && args.includes('reset')) {
try { moveOrganization(f); } catch (e) { refusal = e.code; }
}
}, () => f.choose());
assert.equal(failed, true); assert.equal(refusal, 'organization-busy');
assert.equal(f.writer.read('demo').revision, before); assert.equal(result.wait.code, 'bootstrap-uncertain');
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '1');
assert.equal(git(f.paths.records, 'status', '--porcelain'), '');
assert.notEqual(readFileSync(join(f.paths.records, 'spec/north-star.md'), 'utf8'), INTENT);
assert.equal((await f.bus.requestLifecycle.resume('demo', f.request)).phase, 'discovery');
});
test('c3_broker_reload_microtask_only_serves_after_the_activate_receipt', async t => {
const f = await setup(t), broker = f.bus.broker, original = broker.reloadBusiness;
let observed;
broker.reloadBusiness = function (...args) {
const result = original.apply(this, args);
queueMicrotask(() => {
try { observed = { active: activations(f), admission: admission(f), made: f.call('human', 'project.create', { name: 'After receipt', repo: 'timer' }) }; }
catch (error) { observed = { error }; }
});
return result;
};
let result;
try { result = await f.choose(); } finally { broker.reloadBusiness = original; }
assert.ok(observed); assert.ifError(observed.error);
assert.equal(observed.active.length, 1); assert.equal((await observed.admission).ready, true);
assert.ok(observed.made.id); assert.equal(result.phase, 'discovery');
});
test('c3_activate_receipt_io_failure_keeps_broker_fenced_until_retry', async t => {
const f = await setup(t), original = DatabaseSync.prototype.prepare;
let faulted = false, servedBeforeReceipt = false;
DatabaseSync.prototype.prepare = function (sql, ...rest) {
const statement = original.call(this, sql, ...rest);
if (sql.startsWith('INSERT INTO request_effects(')) {
const run = statement.run;
statement.run = function (...args) {
if (!faulted && String(args[4]).endsWith(':activate') && args[7] === 'committed') {
faulted = true;
try { f.call('human', 'project.create', { name: 'Before receipt', repo: 'timer' }); servedBeforeReceipt = true; } catch {}
throw Object.assign(new Error('injected activation receipt I/O error'), { code: 'SQLITE_IOERR' });
}
return run.apply(this, args);
};
}
return statement;
};
let result;
try { result = await f.choose(); } finally { DatabaseSync.prototype.prepare = original; }
assert.equal(faulted, true); assert.equal(result.phase, 'bootstrap'); assert.equal(result.wait.code, 'bootstrap-uncertain');
assert.equal(marked(f), 'complete'); assert.deepEqual(activations(f), []);
assert.equal((await admission(f)).ready, false);
assert.throws(() => f.call('human', 'project.create', { name: 'After failed receipt', repo: 'timer' }));
assert.equal(servedBeforeReceipt, false);
assert.equal((await f.bus.requestLifecycle.resume('demo', f.request)).phase, 'discovery');
assert.equal(activations(f).length, 1); assert.equal((await admission(f)).ready, true);
assert.ok(f.call('human', 'project.create', { name: 'After retry', repo: 'timer' }).id);
});
// `before(binary, args)` runs just ahead of every child the code spawns while `during` runs.
async function withSpawnHook(before, during) {
const original = cp.spawn;
cp.spawn = function (binary, args, ...rest) { before(binary, args); return original.call(this, binary, args, ...rest); };
syncBuiltinESMExports();
try { return await during(); } finally { cp.spawn = original; syncBuiltinESMExports(); }
}
test('i1_an_organization_write_during_the_intent_reads_leaves_no_intent_commit_under_the_stale_plan', async t => {
let ready = false, changed = false, f;
f = await setup(t, { barrier: step => { if (step === 'records') ready = true; } });
const out = await withSpawnHook((binary, args) => {
if (ready && !changed && binary === 'git' && args.includes(f.paths.records) && args.includes('rev-list') && args.includes('--count')) { changed = true; moveOrganization(f); }
}, () => f.choose());
assert.equal(changed, true, 'the organization write landed between the reads and the write');
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale');
const star = join(f.paths.records, 'spec/north-star.md');
assert.notEqual(existsSync(star) ? readFileSync(star, 'utf8') : null, INTENT, 'the intent was not written');
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '1');
assert.equal(git(f.paths.records, 'log', '-1', '--format=%s'), 'Initialize project layout v1');
assert.equal(git(f.paths.records, 'status', '--porcelain'), '');
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
});
test('i1_an_organization_write_during_the_seed_reads_leaves_the_checkout_unseeded', async t => {
let changed = false, f;
f = await setup(t);
const out = await withSpawnHook((binary, args) => {
if (!changed && binary === 'git' && args.includes(f.paths.code) && args.includes('status')) { changed = true; moveOrganization(f); }
}, () => f.choose());
assert.equal(changed, true);
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale');
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1', 'no seed commit was made');
assert.equal(readFileSync(join(f.paths.code, '.mosaic/project.json'), 'utf8'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
assert.equal(git(f.paths.code, 'status', '--porcelain'), '');
assert.equal(f.additions().length, 0);
});
// I2. The complete marker is published before the broker admits the project, and admission needs both the marker and the
// committed activation receipt, live and after a restart.
const activations = f => {
const database = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
try { return database.prepare('SELECT op, stage FROM request_effects WHERE business = ? AND request = ?').all('demo', f.request).filter(r => r.op.endsWith(':activate') && r.stage === 'committed'); }
finally { database.close(); }
};
const marked = f => JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state;
const admission = f => f.first.bus.admission({ business: 'demo', project: 'timer' });
test('i2_a_failed_marker_publication_ends_the_step_before_the_broker_admits_the_project', async t => {
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
const original = fs.renameSync; let faults = 1;
fs.renameSync = function (from, to, ...rest) {
if (faults && from === `${marker}.tmp` && to === marker) { faults--; throw Object.assign(new Error('injected final publication I/O failure'), { code: 'EIO' }); }
return original.call(this, from, to, ...rest);
};
syncBuiltinESMExports();
let out;
try { out = await f.choose(); } finally { fs.renameSync = original; syncBuiltinESMExports(); }
assert.equal(faults, 0, 'the fault fired');
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
assert.equal(marked(f), 'reserved');
const pending = await admission(f);
assert.equal(pending.ready, false); assert.equal(pending.code, 'project-pending');
assert.deepEqual(activations(f), [], 'no activation receipt was committed');
assert.throws(() => f.call('human', 'project.create', { name: 'Side', repo: 'timer' }), 'the broker does not serve the project');
// The fault is gone: a retry publishes the marker, admits the project and commits the receipt.
const again = await f.first.bus.requestLifecycle.resume('demo', f.request);
assert.equal(again.phase, 'discovery');
assert.equal(marked(f), 'complete'); assert.equal((await admission(f)).ready, true); assert.equal(activations(f).length, 1);
assert.ok(f.call('human', 'project.create', { name: 'Side', repo: 'timer' }).id);
});
// Defence in depth: even if the executor's marker write did nothing, the runtime refuses to admit a product whose marker
// is not complete.
test('i2_the_runtime_refuses_to_admit_a_product_whose_marker_is_not_complete', async t => {
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
const original = fs.renameSync; let skipped = 0;
fs.renameSync = function (from, to, ...rest) {
if (from === `${marker}.tmp` && to === marker) { skipped++; return undefined; }
return original.call(this, from, to, ...rest);
};
syncBuiltinESMExports();
let out;
try { out = await f.choose(); } finally { fs.renameSync = original; syncBuiltinESMExports(); }
assert.ok(skipped >= 1, 'the marker rename was skipped');
assert.equal(out.phase, 'bootstrap');
assert.equal(marked(f), 'reserved');
assert.equal((await admission(f)).ready, false); assert.deepEqual(activations(f), []);
assert.throws(() => f.call('human', 'project.create', { name: 'Side', repo: 'timer' }));
});
test('i2_a_completed_product_whose_marker_is_removed_is_not_ready_live_or_after_reopen', async t => {
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
assert.equal((await f.choose()).phase, 'discovery');
assert.equal((await admission(f)).ready, true);
const bytes = readFileSync(marker);
// Live: the broker is already serving the project, and admission still needs the marker.
rmSync(marker);
const live = await admission(f);
assert.equal(live.ready, false); assert.equal(live.code, 'project-pending');
writeFileSync(marker, bytes, { mode: 0o600 });
assert.equal((await admission(f)).ready, true, 'the marker back makes it ready again');
// After a restart: the committed receipt alone admits nothing, and the marker is not rewritten.
await f.first.bus.close();
rmSync(marker);
const next = await f.boot();
const reopened = await next.bus.admission({ business: 'demo', project: 'timer' });
assert.equal(reopened.ready, false); assert.equal(reopened.code, 'project-pending');
assert.equal(existsSync(marker), false, 'recovery never rewrites a marker it did not write');
assert.equal(next.warnings.length, 0);
await next.bus.close();
writeFileSync(marker, bytes, { mode: 0o600 });
const last = await f.boot();
assert.equal((await last.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
});
+392
View File
@@ -0,0 +1,392 @@
// #797 / #1839 Task 3: the owner's Console controls over the request journal, and the PM's question.
// The webui side is packages/webui/tests/request-http.test.mjs; this file drives the broker verbs
// those routes call, with a real capability for every caller.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Store, SCHEMA_VERSION, schemaDigest } from '../src/store.mjs';
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
import { createRequestStore } from '../src/request-store.mjs';
import { createRequestLifecycle } from '../src/request-lifecycle.mjs';
const hash = x => createHash('sha256').update(x).digest('hex');
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' };
function setup(t, overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'request-console-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm', 'chat'].map(key => [key, {
definition: key === 'otherpm' ? 'pm' : key === 'chat' ? 'assistant' : key, authority: { withinRole: ['message.send'], crossRole: [] },
}])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') },
library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } };
const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({
version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode,
organization_revision: 'org-1', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'),
records_root: existing ? business.projects[args.project].repo : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' },
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
project_vars: {}, existing,
}));
const host = {
listProjects: () => [
{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } },
{ business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'unknown', name: 'Unknown', tracker: null },
],
preparePlans: (state, args, source) => plans(state, args, source, false),
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
admitPlan: () => ({ ready: true }),
};
let lifecycle, journal;
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => {
journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...overrides, ...services } }); return lifecycle;
} });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
console: b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }) };
for (const role of ['pm', 'cto', 'coder', 'otherpm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
const call = async (who, verb, args = {}) => b.request(caps[who], { verb, args });
const source = b.request(caps.human, { verb: 'message.send', args: { to: 'pm', body: intent } });
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
const existing = (state, op) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2 });
return { root, b, store, call, source, propose, existing, business, get journal() { return journal; }, get lifecycle() { return lifecycle; } };
}
const n = (x, sql, ...p) => x.store.get(sql, ...p).n;
const get = (x, who = 'console') => x.call(who, 'request.get.console', { request: x.source.request });
async function atSelection(t) {
const x = setup(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
x.state = await x.call('pm', 'request.propose', x.propose(opened));
return x;
}
async function atPicker(t) {
const x = await atSelection(t);
await x.call('human', 'decision.resolve', { id: x.state.selection.decision, choice: 'existing' });
x.state = await x.lifecycle.resume('demo', x.source.request);
return x;
}
async function atDiscovery(t) {
const x = await atPicker(t);
const picked = await x.call('console', 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
await x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest });
x.state = await x.lifecycle.resume('demo', x.source.request);
assert.equal(x.state.phase, 'discovery');
return x;
}
const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request: state.request, revision: state.revision, op: 'q-1', text: 'Which platform?',
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
const x = await atSelection(t), request = x.source.request;
const args = { 'request.workspace.console': { request }, 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' },
'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' },
'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' },
'request.cancel.console': { request, revision: x.state.revision, op: 'o4' } };
for (const who of ['human', 'pm', 'cto', 'coder', 'otherpm', 'reader', 'tester'])
for (const [verb, a] of Object.entries(args)) await assert.rejects(async () => x.call(who, verb, a), /human-required|read-only|tester-refused/, `${who} ${verb}`);
await assert.rejects(async () => x.call('console', 'request.artifact.console', { request, delivery: 'not-a-delivery' }), /request-not-found/);
for (const verb of ['request.deliver', 'request.get', 'request.projects', 'request.propose', 'request.question', 'request.open'])
await assert.rejects(async () => x.call('console', verb, { request }), /human-required/, verb);
assert.throws(() => x.b.bindConsole({ business: 'demo', human: 'someone', subject: SUBJECT }), /human-required/);
assert.equal(n(x, 'SELECT count(*) n FROM request_events'), 2, 'opened and selection.proposed only');
});
test('the console reads the same state and the same authorized project picker the PM does', async t => {
const x = await atSelection(t);
assert.deepEqual(await get(x), await x.call('pm', 'request.get', { request: x.source.request }));
assert.deepEqual((await x.call('console', 'request.projects.console', { request: x.source.request })).map(p => p.project), ['old']);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: x.source.request, extra: 1 }), /invalid-request/);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: 'absent' }), /request-not-found/);
});
test('the PM asks one question at a time, only after discovery starts, and only the PM may ask', async t => {
const x = await atSelection(t);
await assert.rejects(async () => ask(x, x.state), /request-phase-refused/);
const d = await atDiscovery(t), state = d.state;
for (const who of ['cto', 'coder', 'otherpm', 'human', 'reader', 'tester']) await assert.rejects(async () => d.call(who, 'request.question', { request: state.request, revision: state.revision, op: 'q-x', text: 'No' }), /request-access-refused|agent-required|read-only|tester-refused/, who);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 0);
const asked = await ask(d, state);
assert.equal(asked.question.text, 'Which platform?'); assert.equal(asked.question.answer, null);
assert.deepEqual(asked.question.options, [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }]);
assert.match(asked.question.id, /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/);
assert.equal(asked.wait.kind, 'question'); assert.equal(asked.wait.subject, asked.question.id); assert.equal(asked.revision, state.revision + 1);
assert.deepEqual(await ask(d, state), asked, 'the same op returns the recorded question');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 1);
await assert.rejects(async () => ask(d, state, { text: 'Changed?' }), /request-operation-conflict/);
await assert.rejects(async () => ask(d, asked, { op: 'q-2' }), /request-question-open/);
await assert.rejects(async () => ask(d, state, { op: 'q-3' }), /request-revision-conflict/);
assert.deepEqual(await get(d), asked);
});
test('a guided answer is an attributed human.input linked to its question and request, recorded once per op', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state), request = d.source.request, text = 'Web, please.\nNo installs.';
const args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text };
const answered = await d.call('console', 'request.answer.console', args);
assert.equal(answered.question.answer !== null, true); assert.equal(answered.wait, null); assert.equal(answered.revision, asked.revision + 1);
const ev = d.store.get('SELECT * FROM events WHERE id=?', answered.question.answer);
assert.equal(ev.kind, 'human.input'); assert.equal(ev.actor_role, null); assert.equal(ev.actor_run, null);
const body = JSON.parse(ev.body);
assert.deepEqual(body, { kind: 'answer', request, question: asked.question.id, op: 'ans-1', via: 'console', subject: SUBJECT, message: ev.subject, sha256: hash(text) });
const m = d.store.get('SELECT * FROM messages WHERE id=?', ev.subject);
assert.equal(m.from_role, 'human'); assert.equal(m.from_run, 'human-console'); assert.equal(m.to_role, 'pm'); assert.equal(m.class, 'INFO');
assert.equal(m.body, `Answer to question ${asked.question.id} on request ${request}:\n${text}`);
const received = (await d.call('pm', 'message.receive')).find(r => r.id === m.id);
assert.equal(received.request, ev.id, 'the PM reaches the answer through the ordinary inbox');
assert.equal(d.store.get('SELECT body FROM messages WHERE id=?', d.source.id).body, intent, 'the original message bytes are untouched');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='instruction'"), 1);
// A replay of the same op is the recorded state: no second event, message or journal row.
assert.deepEqual(await d.call('console', 'request.answer.console', args), answered);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op')='ans-1'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 1);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, text: 'Other' }), /request-operation-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2' }), /request-revision-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2', revision: answered.revision }), /request-question-closed/);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1, 'refusals send nothing');
});
test('an answer needs the outstanding question, a stale revision refuses, and a text answer approves nothing', async t => {
const d = await atDiscovery(t), request = d.source.request;
const bare = { request, revision: d.state.revision, question: 'nope', op: 'ans-0', text: 'x' };
await assert.rejects(async () => d.call('console', 'request.answer.console', bare), /request-question-closed/, 'no question is open');
const asked = await ask(d, d.state), args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text: 'ok' };
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, question: 'other-question' }), /request-question-mismatch/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, revision: asked.revision - 1 }), /request-revision-conflict/);
for (const bad of [{ text: '' }, { text: 'x'.repeat(8001) }, { text: 'a\0b' }, { text: 7 }, { note: 'x' }]) await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, ...bad }), /invalid-request/);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.question') IS NOT NULL"), 0);
const answered = await d.call('console', 'request.answer.console', args);
// Text is not an approval: no decision, snapshot, task or selection changed.
assert.equal(answered.snapshot, null); assert.deepEqual(answered.tasks, []); assert.equal(answered.phase, 'discovery');
assert.equal(n(d, 'SELECT count(*) n FROM decisions'), 2, 'the mode decision and the confirmation, nothing more');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='task.created'"), 0);
});
test('attach records the owner pick from the authorized picker, then the PM proposes exactly that project for confirmation', async t => {
const x = await atPicker(t), request = x.source.request;
assert.equal(x.state.wait.code, 'request-existing-project');
const effectsBefore = x.store.all('SELECT * FROM request_effects ORDER BY seq');
const args = { request, revision: x.state.revision, project: 'old', op: 'attach-1' };
for (const project of ['library', 'unknown', 'factory', 'absent']) await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, project, op: 'attach-x' }), /request-project-refused/, project);
await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, revision: 1 }), /request-revision-conflict/);
const picked = await x.call('console', 'request.attach.console', args);
assert.equal(picked.phase, 'selection'); assert.equal(picked.project, null); assert.equal(picked.route, 'existing');
assert.equal(picked.wait.kind, 'setup'); assert.equal(picked.wait.code, 'request-attachment-selected'); assert.equal(picked.wait.subject, 'old');
assert.deepEqual(await x.call('console', 'request.attach.console', args), picked, 'the same op is the recorded pick');
const ev = x.store.get("SELECT * FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='attach'");
assert.deepEqual(JSON.parse(ev.body), { kind: 'attach', request, project: 'old', op: 'attach-1', via: 'console', subject: SUBJECT, message: ev.subject });
assert.equal(x.store.get('SELECT to_role,from_run FROM messages WHERE id=?', ev.subject).to_role, 'pm');
// The pick stages no mapping, effect or decision; keep the existing continuation receipts unchanged.
assert.deepEqual(x.store.all('SELECT * FROM request_effects ORDER BY seq'), effectsBefore);
assert.equal(n(x, 'SELECT count(*) n FROM decisions'), 1);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.existing(picked, 'existing-0'), project: 'library', source_project: 'library', name: 'Library' }), /request-attachment-mismatch|request-project-refused/);
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
assert.equal(candidate.selection.bindings[0].plan.existing, true); assert.equal(candidate.project, null);
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']);
assert.deepEqual(x.business.projects.old, { root: join(x.root, 'old'), repo: join(x.root, 'old-code') }, 'the mapping is not written');
});
test('attach is refused outside the existing-project picker and a pick can be changed before the PM proposes', async t => {
const sel = await atSelection(t);
await assert.rejects(async () => sel.call('console', 'request.attach.console', { request: sel.source.request, revision: sel.state.revision, project: 'old', op: 'a1' }), /request-phase-refused/);
const x = await atPicker(t), request = x.source.request;
const first = await x.call('console', 'request.attach.console', { request, revision: x.state.revision, project: 'old', op: 'a1' });
const again = await x.call('console', 'request.attach.console', { request, revision: first.revision, project: 'old', op: 'a2' });
assert.equal(again.wait.subject, 'old'); assert.equal(again.revision, first.revision + 1);
});
// The owner's mode change is Task 4's request.mode.console: it moves the project's mode revision and every attached request, and
// prd-activation.test.mjs drives it. Task 3 adds no second mode path, so the only mode assertions here are that every non-console
// caller is refused it (the second test above) and that the webui route and button reach it (the webui suites).
test('cancel withdraws an open selection decision and appends cancellation, keeping the source and every resource', async t => {
const x = await atSelection(t), request = x.source.request, decision = x.state.selection.decision;
const args = { request, revision: x.state.revision, op: 'cancel-1' };
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, revision: 1 }), /request-revision-conflict/);
const cancelled = await x.call('console', 'request.cancel.console', args);
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.wait, null); assert.equal(cancelled.selection, null); assert.equal(cancelled.project, null);
const close = x.store.get("SELECT * FROM decision_events WHERE decision=? AND op='withdrawn'", decision);
assert.equal(close.via, 'console'); assert.equal(close.by, 'jason');
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
assert.equal(n(x, 'SELECT count(*) n FROM request_effects'), 0);
assert.deepEqual(await x.call('console', 'request.cancel.console', args), cancelled, 'the same op is the recorded cancellation');
assert.deepEqual(await x.lifecycle.resume('demo', request), cancelled);
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, op: 'cancel-2', revision: cancelled.revision }), /request-phase-refused/);
await assert.rejects(async () => x.call('console', 'decision.preview.console', { id: decision }), /decision-closed/);
});
test('cancel after a project exists keeps the project and its mapping as evidence', async t => {
const x = await atDiscovery(t), before = structuredClone(x.business.projects);
const asked = await ask(x, x.state);
const cancelled = await x.call('console', 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-1' });
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, 'old'); assert.equal(cancelled.mode, 'autonomous');
assert.deepEqual(x.business.projects, before);
const last = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='cancelled'").body);
assert.equal(last.decision, null); assert.equal(last.op, 'cancel-1');
});
test('the journal refuses a forged answer whose human.input is not the owner answer to that question', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state);
const forged = body => d.journal.appendRequest({ business: 'demo', request: d.source.request, expectedRevision: asked.revision, kind: 'question.answered',
actor: { role: null, run: null }, body: { op: 'f1', proposal_digest: 'a'.repeat(64), question: asked.question.id, ...body } });
assert.throws(() => forged({ answer: 'absent' }), /invalid-request-event/);
assert.throws(() => forged({ answer: d.source.request }), /invalid-request-event/, 'the origin instruction is not an answer');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 0);
});
// Review r1 C1: an owner control is a human-to-PM message, so it obeys the messaging policy message.send obeys. The
// policy is read when the control runs, not when the PM asked, and a refusal leaves no evidence behind.
const evidence = x => ({
journal: n(x, 'SELECT count(*) n FROM request_events'),
inputs: n(x, "SELECT count(*) n FROM events WHERE kind='human.input'"),
messages: n(x, 'SELECT count(*) n FROM messages'),
consoleMessages: n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"),
decisionEvents: n(x, 'SELECT count(*) n FROM decision_events'),
});
async function controlUnderRefusedEdge(x, verb, args) {
const state = await get(x), policy = structuredClone(x.business);
policy.definitions = { pm: { messaging: { sendTo: ['human', 'pm'], receiveFrom: ['pm'] } } };
x.b.reloadBusiness('demo', policy);
await assert.rejects(async () => x.call('human', 'message.send', { to: 'pm', body: 'direct' }), /message-sender-refused/, 'message.send refuses this edge');
const before = evidence(x);
await assert.rejects(async () => x.call('console', verb, args), /message-sender-refused/, verb);
assert.deepEqual(evidence(x), before, 'no journal row, human.input, message or decision close survives the refusal');
assert.deepEqual(await get(x), state, 'the request is exactly where it was');
// The refusal was the policy and nothing else: with the edge back, the very same call succeeds once.
x.b.reloadBusiness('demo', x.business);
const done = await x.call('console', verb, args);
assert.equal(n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"), before.consoleMessages + 1, 'one message, from the retry only');
assert.equal(done.revision, state.revision + 1);
return done;
}
test('an answer is refused when the owner-to-PM edge closed after the question, and leaves no evidence', async t => {
const x = await atDiscovery(t), asked = await ask(x, x.state);
const done = await controlUnderRefusedEdge(x, 'request.answer.console',
{ request: x.source.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'the web' });
assert.ok(done.question.answer);
});
test('an attach pick is refused when the owner-to-PM edge closed, and leaves no evidence', async t => {
const x = await atPicker(t);
const done = await controlUnderRefusedEdge(x, 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
assert.equal(done.revision, x.state.revision + 1);
});
test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn decision and no cancellation', async t => {
const open = await atSelection(t), decision = open.state.selection.decision;
const done = await controlUnderRefusedEdge(open, 'request.cancel.console', { request: open.source.request, revision: open.state.revision, op: 'cancel-1' });
assert.equal(done.phase, 'cancelled');
assert.equal(n(open, "SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", decision), 1, 'withdrawn once, by the retry');
const x = await atDiscovery(t), asked = await ask(x, x.state);
assert.equal((await controlUnderRefusedEdge(x, 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-2' })).phase, 'cancelled');
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3t');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
const current = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
const db = new DatabaseSync(path); db.exec(ddl3q);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3q');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', AT);
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m1',?,'demo','human','human','pm','info','build a timer')").run(AT);
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r1',?,'demo','human.input','m1',?)").run(AT, JSON.stringify({ message: 'm1', kind: 'instruction' }));
const add = (revision, kind) => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')").run(revision, kind, AT);
add(1, 'opened'); add(2, 'setup.wait'); add(3, 'prd.submitted'); add(4, 'mode.changed');
assert.throws(() => add(5, 'question.asked'), /CHECK constraint failed/, 'the 3q schema refuses the new kinds');
const before = db.prepare('SELECT * FROM request_events ORDER BY seq').all();
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_digest'").value, schemaDigest(fresh), 'the replayed chain is the fresh schema');
fresh.close();
const next = (revision, kind) => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')", revision, kind, AT);
for (const [i, kind] of ['question.asked', 'question.answered', 'attach.selected'].entries()) next(5 + i, kind);
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 7, 'the sequence carries on from the old table');
next(8, 'prd.activated'); next(9, 'prd.invalidated');
assert.throws(() => next(9, 'cancelled'), /append-only/, 'a replayed revision is refused');
assert.throws(() => next(12, 'cancelled'), /request-revision-conflict/, 'the revision guard is back');
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','nope',1,'opened',?,'{}')", AT), /request-origin-refused/);
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',10,'surprise',?,'{}')", AT), /CHECK constraint failed/);
assert.throws(() => store.run("UPDATE request_events SET kind='opened' WHERE seq=1"), /append-only/);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
store.close();
assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0, 'a second run is a no-op');
});
test('a thread message row carries its request id for the owner chat and for no one else', async t => {
const x = setup(t);
const project = (await x.call('human', 'project.create', { name: 'timer work', repo: 'factory' })).id;
const thread = (await x.call('human', 'thread.create', { project, name: 'timer', instance: 'chat' })).id;
const sent = await x.call('human', 'thread.send', { thread, body: intent });
assert.equal(typeof sent.request, 'string');
const rows = await x.call('human', 'thread.messages', { thread });
assert.equal(rows.length, 1); assert.equal(rows[0].id, sent.id); assert.equal(rows[0].request, sent.request, 'the owner reads the request id the panel mounts on');
const bare = x.store.get('SELECT * FROM messages WHERE id=?', sent.id);
assert.deepEqual({ ...rows[0], request: undefined }, { ...bare, request: undefined }, 'every other field is the stored row');
const tester = x.b.bindTester({ business: 'demo' }), tt = (await x.b.request(tester, { verb: 'thread.create', args: { project, name: 'tt', instance: 'chat' } })).id;
await x.b.request(tester, { verb: 'thread.send', args: { thread: tt, body: 'hello' } });
for (const row of await x.b.request(tester, { verb: 'thread.messages', args: { thread: tt } })) assert.equal('request' in row, false, 'a tester row keeps its old shape');
});
for (const operation of ['attach', 'confirm']) test(`archive during async ${operation} inventory refuses before binding a request`, async t => {
let archive = null;
const x = setup(t, { listProjects: async () => {
await Promise.resolve();
if (archive) { const run = archive; archive = null; await run(); }
return [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } }];
} });
const project = (await x.call('human', 'project.create', { name: 'Old', repo: 'old' })).id;
const opened = await x.call('pm', 'request.open', { request: x.source.request });
let state = await x.call('pm', 'request.propose', x.propose(opened));
await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' });
state = await x.lifecycle.resume('demo', x.source.request);
if (operation === 'confirm') {
state = await x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'pick-old' });
state = await x.call('pm', 'request.propose', x.existing(state, 'existing-old'));
await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'confirm' });
}
archive = () => x.call('human', 'project.archive', { project });
if (operation === 'attach') {
await assert.rejects(x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'late-pick' }), { code: 'project-archived' });
assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='attach.selected'"), 0);
} else {
state = await x.lifecycle.resume('demo', x.source.request);
assert.equal(state.wait.code, 'project-archived');
assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='selection.confirmed'"), 0);
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,182 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
test('delivery requires the delivery phase even with a valid typed request', async t => {
const f = await requestFixture(t);
const s = f.journal.readRequest('demo', f.request);
await assert.rejects(Promise.resolve().then(() => f.call('pm', 'request.deliver', { request: f.request, revision: s.revision, op: 'delivery-1', task_ref: 'vikunja:2/41', verdict: 'verdict-1', path: 'timer.html', verification_record: { path: 'docs/verification.md', commit: 'a'.repeat(40) } })), /request-phase-refused/);
});
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { git } from '../../project/tests/prd-fixture.mjs';
import { ready } from './delivery-fixture.mjs';
test('approved delivery binds exact blob, verification, task and review, and retries once', async t => {
const f = await ready(t);
const outcome = await f.call('pm', 'request.deliver', f.args);
assert.equal(outcome.commit, f.commit); assert.equal(outcome.task_ref, f.task_ref);
assert.equal(outcome.review_request, f.review.request); assert.equal(outcome.verdict, f.verdict.verdict);
assert.equal(outcome.artifact.blob, git(join(f.f.dataRoot, 'working/demo/timer.git'), 'rev-parse', `${f.commit}:timer.html`));
assert.equal(outcome.artifact.sha256, createHash('sha256').update(f.bytes).digest('hex'));
assert.equal(outcome.verification.record_commit, f.verification.commit);
assert.deepEqual(await f.call('pm', 'request.deliver', f.args), outcome);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete');
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 1);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 1);
const artifact = await f.call('console', 'request.artifact.console', { request: f.request, delivery: outcome.id });
assert.deepEqual(Buffer.from(artifact.base64, 'base64'), f.bytes);
});
test('a different operation for the same reviewed artifact returns the original delivery', async t => {
const f = await ready(t); const first = await f.call('pm', 'request.deliver', f.args);
const again = await f.call('pm', 'request.deliver', { ...f.args, op: 'retry-different-op', revision: f.args.revision + 1 });
assert.deepEqual(again, first);
assert.equal(f.store.get("SELECT count(*) n FROM request_events WHERE request=? AND kind='advanced' AND json_extract(body,'$.phase')='complete'", f.request).n, 1);
});
test('a newer push or wrong task cannot borrow an earlier verdict', async t => {
const f = await ready(t);
await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, op: 'wrong-task', task_ref: 'vikunja:2/42' }), /request-evidence-required/);
f.push('c'.repeat(40));
await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-evidence-required/);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
});
test('a forged verification record cannot borrow the reviewer receipt', async t => {
const f = await ready(t);
const fake = await f.call('pm', 'record.write', { project: 'timer', path: 'docs/forged.md', record: JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: f.review.request, commit: f.commit, results: 'Acceptance: timer starts and stops offline; passed.' }) });
await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, verification_record: { path: fake.path, commit: fake.commit } }), /request-verification-refused/);
});
test('delivery and chief intent roll back together when the notification cannot be recorded', async t => {
const f = await ready(t);
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close());
db.exec("CREATE TRIGGER fail_delivery_notice BEFORE INSERT ON messages WHEN NEW.from_role='_bus' AND NEW.to_role='chief' AND NEW.body LIKE 'Request continuation %' BEGIN SELECT RAISE(ABORT,'delivery-notice-failed'); END");
await assert.rejects(f.call('pm', 'request.deliver', f.args), /delivery-notice-failed|request-failed|ERR_SQLITE_ERROR/);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0);
db.exec('DROP TRIGGER fail_delivery_notice');
const delivered = await f.call('pm', 'request.deliver', f.args);
const intent = f.journal.effects('demo', f.request).find(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message');
assert.equal(delivered.message, intent.result.message);
});
test('verification results must equal those cited in the review verdict', async t => {
const f = await ready(t, { results: 'Different results' });
await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-verification-refused/);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
});
test('reviewed downloaded HTML opens offline and matches the committed blob', async t => {
const f = await ready(t); const d = await f.call('pm', 'request.deliver', f.args);
const { requestHTTP } = await import('../../webui/src/request-http.mjs');
const { browser } = await import('../../webui/tests/browser.mjs');
const { pathToFileURL } = await import('node:url');
const session = { name: 'jason', provider: 'test', iss: 'test', sub: 'owner' };
const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } };
await requestHTTP({ method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } }, res, {
path: `/api/requests/${f.request}/artifact/${d.id}`, session, live: () => session, origin: 'https://console.test', log() {}, requestWrite: null,
requestRead: (verb, args) => f.call('console', verb, args),
});
assert.equal(res.status, 200); assert.deepEqual(res.data, f.bytes);
const downloaded = join(f.f.dataRoot, 'downloaded-timer.html'); writeFileSync(downloaded, res.data);
const b = await browser({ machine: false }); t.after(() => b.close());
await b.call('Network.enable');
await b.call('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 });
await b.navigate(pathToFileURL(downloaded).href);
assert.equal(await b.evaluate('navigator.onLine'), false);
assert.equal(await b.evaluate("document.querySelector('#start').click(); document.querySelector('#clock').textContent"), 'Running');
assert.equal(createHash('sha256').update(res.data).digest('hex'), d.artifact.sha256);
assert.equal(git(join(f.f.dataRoot, 'working/demo/timer.git'), 'rev-parse', `${d.commit}:timer.html`), d.artifact.blob);
});
test('a later changes verdict cannot deliver the previous approval', async t => {
const f = await ready(t); const review = f.call('coder', 'review.request', { task_ref: f.task_ref, commit: f.commit, to: 'reviewer' });
f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: review.request, verdict: 'changes', body: 'Needs work' });
await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-evidence-required/);
});
test('delivery rejects traversal, symlinks and oversized blobs', async t => {
const f = await ready(t, { invalidPaths: true });
for (const [i, path] of ['../timer.html', 'link.html', 'large.html'].entries()) {
await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, op: `invalid-path-${i}`, path }), /artifact-path-invalid|artifact-not-regular|artifact-too-large/);
}
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
});
test('delivery remains readable and notification recoverable when the assistant is unavailable', async t => {
const f = await ready(t);
f.bus.broker.endLaunch({ business: 'demo', run: 'assistant-run', reason: 'exit', exitCode: 1 });
const d = await f.call('pm', 'request.deliver', f.args);
const result = await f.call('console', 'request.artifact.console', { request: f.request, delivery: d.id });
assert.deepEqual(Buffer.from(result.base64, 'base64'), f.bytes);
for (let i = 0; i < 2; i++) await f.bus.requestLifecycle.recover();
const pending = f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message');
assert.equal(pending.length, 1);
assert.equal(pending[0].result.message, d.message);
});
test('changed PRD records cannot authorize a new delivery', async t => {
const f = await ready(t);
await f.call('pm', 'record.write', { project: 'timer', path: 'docs/prd-1.0.md', record: '# Changed PRD\n' });
await assert.rejects(f.call('pm', 'request.deliver', f.args), /prd-candidate-stale|requirement-snapshot-revoked/);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0);
});
test('real task.close through the tracker adapter supplies the closure used by delivery', async t => {
const f = await ready(t, { realClose: true });
assert.ok(f.fake, 'the delivery fixture uses the real task adapter and fake tracker');
assert.ok(f.fake.requests.some(r => r.method === 'PUT' && /^\/projects\/\d+\/views\/\d+\/buckets\/\d+\/tasks$/.test(r.path)), 'task.close wrote to the tracker');
const d = await f.call('pm', 'request.deliver', f.args);
assert.equal(d.verdict, f.verdict.verdict);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete');
});
async function newerUnclosedReview(f) {
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const config = bootConfig({ system: loadSystem({ env: f.f.env }), businessId: 'demo', env: f.f.env, warn: () => {} });
const business = config.businesses.demo;
business.roles.reviewer2 = structuredClone(business.roles.reviewer);
business.roles.reviewer2.tracker = { bot: 'bot-demo-reviewer2', botId: f.fake.user('reviewer2') };
business.launch.instances.push('reviewer2');
f.bus.broker.reloadBusiness('demo', business);
const cap = f.bus.broker.bindLaunch({ business: 'demo', role: 'reviewer2', run: 'reviewer2-run', harness: 'pi' });
f.bus.broker.request(cap, { verb: 'role.claim' });
const review = f.call('coder', 'review.request', { task_ref: f.task_ref, commit: f.commit, to: 'reviewer2' });
const results = 'Reviewer two independently passed acceptance.';
const verification = await f.bus.broker.request(cap, { verb: 'record.write', args: { project: 'timer', path: 'docs/verification2.md',
record: JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: review.request, commit: f.commit, results }) } });
const verdict = f.bus.broker.request(cap, { verb: 'review.verdict', args: { task_ref: f.task_ref, request: review.request, verdict: 'approve',
body: JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results }) } });
return { review, verdict, verification };
}
test('refuses a newer approval whose verdict was never cited by the task closure', async t => {
const f = await ready(t, { realClose: true }), v2 = await newerUnclosedReview(f);
const closures = f.store.all("SELECT body FROM events WHERE business=? AND kind='task.closed' AND subject=?", 'demo', f.task_ref);
assert.equal(closures.length, 1);
assert.equal(JSON.parse(closures[0].body).verdict, f.verdict.verdict);
await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, verdict: v2.verdict.verdict,
verification_record: { path: v2.verification.path, commit: v2.verification.commit } }), { code: 'request-evidence-required' });
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
assert.equal(f.store.get("SELECT count(*) n FROM request_events WHERE business=? AND request=? AND kind='advanced' AND json_extract(body,'$.phase')='complete'", 'demo', f.request).n, 0);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 0);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0);
});
test('delivers the closed planned review even after another reviewer gives a newer unclosed approval', async t => {
const f = await ready(t, { realClose: true }), v2 = await newerUnclosedReview(f);
const d = await f.call('pm', 'request.deliver', f.args);
assert.equal(d.review_request, f.review.request);
assert.equal(d.verdict, f.verdict.verdict);
assert.notEqual(d.verdict, v2.verdict.verdict);
assert.equal(d.verification.record_commit, f.verification.commit);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete');
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 1);
});
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { prdFixture, git } from '../../project/tests/prd-fixture.mjs';
import { createRequestStore } from '../src/request-store.mjs';
export async function requestFixture(t, mode = 'autonomous', beforeBootstrap = null, threaded = false) {
const root = tmp(t, 'activation-'), f = fixture(root, 'demo', d => { d.launch.instances.push('pm'); if (threaded) d.roles.assistant = { definition: 'assistant' }; return d; });
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1 } }));
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
const system = loadSystem({ env: f.env });
const boot = async () => {
const config = bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} });
const bus = await startBroker(config); t.after(() => bus.close().catch(() => {})); return bus;
};
const bus = await boot(), caps = {};
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
const bind = b => {
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.console = b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
caps.reader = b.bindReader({ business: 'demo' });
for (const role of ['pm','cto','coder','reviewer','chief']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); }
};
bind(bus.broker);
const call = (who, verb, args = {}) => bus.broker.request(caps[who], { verb, args });
await beforeBootstrap?.({ f, bus, caps, call, system });
let sent;
if (threaded) {
const project = call('human', 'project.create', { name: 'Intake' }).id;
const thread = call('human', 'thread.create', { project, name: 'Timer' }).id;
caps.assistant = bus.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
call('assistant', 'role.claim');
sent = call('human', 'thread.send', { thread, body: 'Build a timer.\n' });
const relay = call('assistant', 'message.send', { to: 'chief', in_reply_to: sent.id, body: 'Original request ' + sent.request });
call('chief', 'message.send', { to: 'pm', in_reply_to: relay.id, body: 'Open original request ' + sent.request });
} else sent = call('human', 'message.send', { to: 'pm', body: 'Build a timer.\n' });
let state = await call('pm', 'request.open', { request: sent.request });
state = await call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'plan', source_project: 'stack', project: 'timer', name: 'Timer', tracker_project: 2 });
call('human', 'decision.resolve', { id: state.selection.decision, choice: mode });
state = await bus.requestLifecycle.resume('demo', sent.request);
const context = { business: { id: 'demo', human: 'jason', projects: { timer: { root: join(f.dataRoot, 'products/demo/timer/code') } } }, project: 'timer', dataRoot: f.dataRoot };
const p = await prdFixture(t, context);
const args = { request: sent.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: p.args.records_commit, prd_path: p.args.prd_path, requirement_ids: p.args.requirement_ids };
const submit = () => call('pm', 'prd.submit', args);
const approve = (submission, who = mode === 'guided' ? 'human' : 'cto') => call(who, 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
const activate = submission => bus.requirementCatalog.activate({ request: sent.request, decision: submission.decision.id });
return { ...p, f, bus, system, caps, call, source: sent, state, request: sent.request, args, submit, approve, activate, boot, bind, config: bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} }), store, journal: createRequestStore(store) };
}
+623
View File
@@ -0,0 +1,623 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
const journalModule = await import('../src/request-store.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e;
});
const lifecycleModule = await import('../src/request-lifecycle.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e;
});
const hash = x => createHash('sha256').update(x).digest('hex');
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
function setup(t, { prepared = false, authority = true, halted = false, configureHost = () => {}, userText = intent } = {}) {
assert.equal(typeof journalModule.createRequestStore, 'function', 'host intake journal exists');
assert.equal(typeof lifecycleModule.createRequestLifecycle, 'function', 'host lifecycle exists');
const root = mkdtempSync(join(tmpdir(), 'request-intake-'));
let store = new Store(root), lifecycle, journal, cleanupRuntime;
let processingHalted = false;
t.after(async () => { await cleanupRuntime?.close(); store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm'].map(key => [key, {
definition: key === 'otherpm' ? 'pm' : key,
authority: { withinRole: authority || key !== 'pm' ? ['message.send'] : [], crossRole: [] },
}])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') },
library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } };
let revision = 'org-1';
const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({
version: 1, business: state.business, request: state.request, project: args.project,
name: args.name, chat_project: null, mode, organization_revision: revision,
source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'),
records_root: existing ? (business.projects[args.project].repo ?? join(root, 'projects/demo', args.project)) : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' },
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
project_vars: {}, existing,
}));
const host = {
listProjects: () => [
{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } },
{ business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'unknown', name: 'Unknown', tracker: null },
],
...(prepared ? {
preparePlans: (state, args, source) => plans(state, args, source, false),
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
admitPlan: plan => plan.organization_revision === revision ? { ready: true } : { ready: false, code: 'organization-stale' },
} : {}),
};
configureHost(host);
const factory = services => {
journal = journalModule.createRequestStore(store);
lifecycle = lifecycleModule.createRequestLifecycle({ journal, services: { ...host, ...services } });
return lifecycle;
};
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: factory,
...(halted ? { processing: { read: () => ({ version: 1, halted: processingHalted, generation: '12345678-1234-4234-8234-123456789abc' }) } } : {}) });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
console: b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'jason' } }) };
for (const role of ['pm', 'cto', 'coder', 'otherpm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const source = call('human', 'message.send', { to: 'pm', body: userText });
processingHalted = halted;
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1',
source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
return { root, b, store, journal, lifecycle, host, call, source, propose, business,
runtimeCleanup: runtime => { cleanupRuntime = runtime; },
changeOrganization: () => { revision = 'org-2'; },
reopen: () => { store.close(); store = new Store(root); return journalModule.createRequestStore(store); } };
}
test('opens_without_task_or_requirement', async t => {
const x = setup(t), state = await x.call('pm', 'request.open', { request: x.source.request });
assert.equal(state.request, x.source.request); assert.equal(state.source_message, x.source.id);
assert.equal(state.phase, 'selection'); assert.equal(state.owner, 'jason'); assert.equal(state.project, null);
assert.deepEqual(state.tasks, []); assert.equal(state.snapshot, null);
assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0);
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
});
test('duplicate_open_is_same_request', async t => {
const x = setup(t), args = { request: x.source.request };
const [a, b] = await Promise.all([x.call('pm', 'request.open', args), x.call('pm', 'request.open', args)]);
assert.deepEqual(a, b); assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 1);
assert.deepEqual(await x.call('human', 'request.get', args), a);
});
test('rejects_foreign_input', async t => {
const x = setup(t);
for (const request of [x.source.id, 'absent']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/);
x.store.run("INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)", 'foreign', new Date().toISOString(), 'foreign', 'human.input', JSON.stringify({ kind: 'instruction', message: x.source.id }));
x.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)", 'forged', new Date().toISOString(), 'demo', 'human.input', 'coder', 'coder-run', x.source.id, JSON.stringify({ kind: 'instruction', message: x.source.id }));
for (const request of ['foreign', 'forged']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/);
await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request, text: 'forged' }), /invalid-request/);
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
});
test('non_owner_cannot_select', async t => {
const x = setup(t, { prepared: true });
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(opened));
for (const who of ['pm', 'cto', 'coder', 'otherpm']) assert.throws(() => x.call(who, 'decision.resolve', { id: state.selection.decision, choice: 'guided' }), /human-required/);
await assert.rejects(async () => x.call('otherpm', 'request.propose', x.propose(state)), /request-access-refused/);
const preview = x.call('console', 'decision.preview.console', { id: state.selection.decision });
assert.equal(preview.kind, 'decision'); assert.deepEqual(preview.options.map(o => o.key), ['autonomous', 'guided', 'existing']);
const d = x.store.get('SELECT * FROM decisions WHERE id=?', state.selection.decision);
assert.equal(d.route_to, 'human'); assert.equal(d.action, 'project.bootstrap'); assert.equal(d.blocking, 0);
assert.equal(d.task_ref, null); assert.equal(d.requirement_ref, null);
const raise = JSON.parse(x.store.get("SELECT body FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.operation')='decision.raise'", d.id).body);
assert.equal(raise.approvalChoice, 'autonomous');
});
test('reopen_store_reduces_same_state', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(a));
assert.deepEqual(x.reopen().readRequest('demo', x.source.request), state);
});
test('missing_resources_opens_one_setup_wait', async t => {
const x = setup(t), a = await x.call('pm', 'request.open', { request: x.source.request });
const one = await x.call('pm', 'request.propose', x.propose(a));
assert.equal(one.wait.kind, 'setup'); assert.equal(one.wait.code, 'request-preparation-unavailable');
assert.deepEqual(await x.lifecycle.resume('demo', a.request), one);
assert.deepEqual(await x.lifecycle.recover(), [one]);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='setup.wait'").n, 1);
});
test('guided_is_a_selection_and_execution_waits_for_later_capability', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(a));
x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'guided' });
const selected = await x.lifecycle.resume('demo', a.request);
assert.equal(selected.mode, 'guided'); assert.equal(selected.phase, 'bootstrap');
assert.equal(selected.plan_digest, state.selection.bindings.find(b => b.choice === 'guided').digest);
assert.equal(selected.wait.code, 'request-bootstrap-unavailable');
assert.deepEqual(await x.lifecycle.resume('demo', a.request), selected);
assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0);
assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0);
});
test('stale_revision_and_changed_plan_refuse_before_confirmation', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(a));
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(a), op: 'plan-2' }), /request-revision-conflict/);
x.changeOrganization(); x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'autonomous' });
const stopped = await x.lifecycle.resume('demo', a.request);
assert.equal(stopped.phase, 'selection'); assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'organization-stale');
});
test('existing_picker_filters_authorized_software_mappings_then_confirms_exact_association', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(a));
assert.deepEqual((await x.call('pm', 'request.projects', { request: a.request })).map(p => p.project), ['old']);
x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' });
const picker = await x.lifecycle.resume('demo', a.request);
assert.equal(picker.phase, 'selection'); assert.equal(picker.project, null); assert.equal(picker.wait.code, 'request-existing-project');
const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'existing-1', source_project: 'old', project: 'old', name: 'Old' });
assert.equal(candidate.project, null); assert.equal(candidate.selection.bindings[0].plan.existing, true);
const d = x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']);
x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest });
const bound = await x.lifecycle.resume('demo', a.request);
assert.equal(bound.project, 'old'); assert.equal(bound.snapshot, null); assert.deepEqual(bound.tasks, []);
assert.equal(bound.wait.code, 'request-attachment-unavailable');
});
// A mapping with no `repo` keeps its records repository at the data-root default; the guard must compare
// code_root to `root` and records_root to that default, not the other way round.
test('existing_project_without_a_repo_binds_its_root_and_the_default_records_repository', async t => {
const x = setup(t, { prepared: true });
delete x.business.projects.old.repo;
x.b.reloadBusiness('demo', x.business);
const picker = await reachPicker(x);
const candidate = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
const [{ plan }] = candidate.selection.bindings;
assert.equal(plan.existing, true);
assert.equal(plan.code_root, x.business.projects.old.root);
assert.equal(plan.records_root, join(x.root, 'projects/demo/old'));
});
// The Task 1 guard compared the two roots crosswise, so a plan that swaps them was the one it accepted.
for (const repo of [true, false]) test(`existing_project_plan_with_swapped_roots_is_refused_${repo ? 'with' : 'without'}_a_repo`, async t => {
const x = setup(t, { prepared: true, configureHost: host => {
const real = host.prepareExisting;
host.prepareExisting = (...a) => { const p = real(...a); return { ...p, code_root: p.records_root, records_root: p.code_root }; };
} });
if (!repo) { delete x.business.projects.old.repo; x.b.reloadBusiness('demo', x.business); }
const picker = await reachPicker(x);
await assert.rejects(async () => x.call('pm', 'request.propose', existingArgs(picker, 'swapped-1')), /request-plan-binding-refused/);
});
test('withdrawal_cancels_without_resources_and_preserves_source', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(a));
x.call('human', 'decision.withdraw', { id: state.selection.decision });
const cancelled = await x.lifecycle.resume('demo', a.request);
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, null);
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0);
});
test('journal_cas_strict_events_and_append_only_sql_guards', async t => {
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'setup.wait',
actor: { role: null, run: null }, body: { code: 'resource-missing', subject: state.request } };
x.journal.appendRequest(event);
assert.throws(() => x.journal.appendRequest(event), /request-revision-conflict/);
assert.throws(() => x.journal.appendRequest({ ...event, expectedRevision: 2, body: { ...event.body, state: {} } }), /invalid-request-event/);
for (const sql of ['UPDATE request_events SET kind=kind', 'DELETE FROM request_events',
'INSERT OR REPLACE INTO request_events SELECT * FROM request_events']) assert.throws(() => x.store.run(sql), /append-only/);
});
test('effect_receipts_reserve_digest_kind_and_terminal_result_once', async t => {
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
const r = { version: 1, business: 'demo', request: state.request, op: 'boot-1', kind: 'bootstrap', digest: hash('plan'), stage: 'prepared', result: null, code: null };
assert.deepEqual(x.journal.prepareEffect(r), r); assert.deepEqual(x.journal.prepareEffect(r), r);
assert.throws(() => x.journal.prepareEffect({ ...r, digest: hash('other') }), /request-effect-conflict/);
const done = { ...r, stage: 'committed', result: { project: 'timer' } };
assert.deepEqual(x.journal.finishEffect(done), done); assert.deepEqual(x.journal.finishEffect(done), done);
assert.deepEqual(x.journal.prepareEffect(r), done, 'retry receives the committed evidence, never a fresh execution reservation');
assert.throws(() => x.journal.finishEffect({ ...done, result: { project: 'other' } }), /request-effect-conflict/);
assert.throws(() => x.journal.finishEffect({ ...done, op: 'unprepared' }), /request-effect-unprepared/);
for (const sql of ['UPDATE request_effects SET kind=kind', 'DELETE FROM request_effects',
'INSERT OR REPLACE INTO request_effects SELECT * FROM request_effects']) assert.throws(() => x.store.run(sql), /append-only/);
});
test('request_verbs_retain_role_grant_processing_and_origin_visibility_gates', async t => {
const x = setup(t, { authority: false });
await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request }), /decision-required/);
for (const who of ['reader','tester','coder','cto','otherpm']) await assert.rejects(async () => x.call(who, 'request.open', { request: x.source.request }), /read-only|tester-refused|request-access-refused|request-origin-refused/);
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
const halted = setup(t, { halted: true });
await assert.rejects(async () => halted.call('pm', 'request.open', { request: halted.source.request }), /processing-halted/);
assert.equal(halted.store.get('SELECT count(*) n FROM request_events').n, 0);
});
test('proposal_is_idempotent_but_op_revision_plan_and_resource_bindings_are_strict', async t => {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const args = x.propose(a), state = await x.call('pm', 'request.propose', args);
assert.deepEqual(await x.call('pm', 'request.propose', args), state);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...args, name: 'Other' }), /request-operation-conflict/);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 1);
const proposed = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='selection.proposed'").body);
const plan = proposed.bindings[0].plan;
const bad = structuredClone(plan); bad.intent.text = 'Forged original'; bad.intent.sha256 = hash(bad.intent.text);
assert.throws(() => x.journal.appendRequest({ business: 'demo', request: a.request, expectedRevision: state.revision,
kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: { ...proposed,
bindings: [{ ...proposed.bindings[0], plan: bad, digest: journalModule.digest(bad) }, proposed.bindings[1]] } }), /invalid-request-event/);
});
test('plan_preparation_rejects_unknown_keys_changed_intent_tracker_modes_and_symlinks', async t => {
for (const fault of ['extra','intent','tracker','mode','symlink','partial','overlap']) {
const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request });
const original = x.host.preparePlans;
x.host.preparePlans = (state, args, source) => {
const plans = original(state, args, source);
if (fault === 'extra') plans[0].authority = 'all';
if (fault === 'intent') { plans[0].intent.text = 'forged'; plans[0].intent.sha256 = hash('forged'); }
if (fault === 'tracker') plans[0].tracker.project = 99;
if (fault === 'mode') plans[1].source.commit = 'b'.repeat(40);
return plans;
};
// The factory takes a host snapshot; construct a second controller for these adversarial hooks.
const fakeIdentity = { business: 'demo', role: 'pm', run: 'pm-run' };
const controller = lifecycleModule.createRequestLifecycle({ journal: x.journal, services: { ...x.host, dataRoot: x.root,
origin: () => ({ owner: 'jason', pm: 'pm', source_message: x.source.id, thread: null, session: null, text: intent }),
authorize: () => {}, project: (_b, p) => x.business.projects[p]?.kind === 'library' ? null : x.business.projects[p],
projectPaths: () => Object.values(x.business.projects).flatMap(p => [p.root, p.repo]).filter(Boolean),
raiseDecision: () => assert.fail('invalid plan must not raise'), readDecision: () => null } });
if (fault === 'symlink') symlinkSync(x.root, join(x.root, 'products'));
if (fault === 'partial') { const parent = join(x.root, 'products/demo/timer'); mkdirSync(parent, { recursive: true }); writeFileSync(join(parent, 'unrelated.txt'), 'unrelated'); }
if (fault === 'overlap') x.business.projects.other = { root: join(x.root, 'products/demo/timer') };
await assert.rejects(() => controller.propose(fakeIdentity, x.propose(a)), /invalid-request-plan|request-plan-binding-refused|request-plan-path-collision/, fault);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0, fault);
}
});
test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_schema', t => {
const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
// Tasks 2, 3, 4 and 6b (3o→3p, 3p→3q, 3q→3r, 3r→3s) widen only request_events.kind, so the 3n schema is the current one with the old list.
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
const { schemaDigest, SCHEMA_VERSION } = awaitImportStore;
const db = new DatabaseSync(path); db.exec(oldDDL);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version','3n');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest',schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch','2026-11-03T00:00:00.000Z');
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-03T00:00:00.000Z','demo','action.allowed','{}')").run();
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
// The actual base engine refuses the migrated fixture. No live config or data is read.
const moduleRoot = join(root, 'old-engine'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
writeFileSync(join(moduleRoot, 'schema.sql'), oldDDL);
const oldSource = spawnSync('git', ['show','1e1357cb6e97825f6080bee15ab45efce794b137:packages/bus/src/store.mjs'], { encoding: 'utf8' });
assert.equal(oldSource.status, 0); writeFileSync(join(moduleRoot, 'src/store.mjs'), oldSource.stdout);
for (const migration of ['3b','3b-3c','3c-3d','3d-3e','3e-3f','3f-3g','3g-3h','3h-3i','3i-3j','3j-3k','3k-3l','3l-3m','3m-3n'])
writeFileSync(join(moduleRoot, 'src/migrations', migration + '.sql'), readFileSync(new URL('../src/migrations/' + migration + '.sql', import.meta.url)));
const old = spawnSync(process.execPath, ['--input-type=module','-e', `import {Store} from ${JSON.stringify(join(moduleRoot, 'src/store.mjs'))}; try { new Store(${JSON.stringify(root)}); process.exit(9); } catch(e) { if(e.message !== 'schema-mismatch') throw e; }`], { encoding: 'utf8' });
assert.equal(old.status, 0, old.stderr);
});
const awaitImportStore = await import('../src/store.mjs');
test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t => {
const root = mkdtempSync(join(tmpdir(), 'request-migration-3p-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite');
// The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', AT);
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m1',?,'demo','human','human','pm','info','build a timer')").run(AT);
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r1',?,'demo','human.input','m1',?)").run(AT, JSON.stringify({ message: 'm1', kind: 'instruction' }));
const add = (revision, kind) => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')").run(revision, kind, AT);
add(1, 'opened'); add(2, 'setup.wait');
assert.throws(() => add(3, 'bootstrap.stale'), /CHECK constraint failed/, 'the 3o schema refuses the new kind');
db.prepare("INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage) VALUES(?,1,'demo','r1','bootstrap:x:reserve','bootstrap',?,'prepared')").run(AT, 'a'.repeat(64));
const before = db.prepare('SELECT * FROM request_events ORDER BY seq').all(), effects = db.prepare('SELECT * FROM request_effects ORDER BY seq').all();
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
const next = (revision, kind) => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')", revision, kind, AT);
next(3, 'bootstrap.stale');
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 3, 'the sequence carries on from the old table');
assert.throws(() => next(3, 'bootstrap.completed'), /append-only/, 'a replayed revision is refused');
assert.throws(() => next(5, 'selection.proposed'), /request-revision-conflict/, 'the revision guard is back');
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','nope',1,'opened',?,'{}')", AT), /request-origin-refused/, 'the origin guard is back');
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',4,'surprise',?,'{}')", AT), /CHECK constraint failed/);
assert.throws(() => store.run("UPDATE request_events SET kind='opened' WHERE seq=1"), /append-only/);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage) VALUES(?,1,'demo','r1','bootstrap:x:reserve','delivery',?,'prepared')", AT, 'a'.repeat(64)), /request-effect-conflict/, 'the effect guard is back');
store.close();
assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0, 'a second run is a no-op');
// The Task 1 engine (3o) refuses the migrated fixture and does not change it.
const migrated = readFileSync(path), moduleRoot = join(root, 'old-engine'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3o);
const oldSource = spawnSync('git', ['show', '138171ca0:packages/bus/src/store.mjs'], { encoding: 'utf8' });
assert.equal(oldSource.status, 0); writeFileSync(join(moduleRoot, 'src/store.mjs'), oldSource.stdout);
for (const m of ['3b','3b-3c','3c-3d','3d-3e','3e-3f','3f-3g','3g-3h','3h-3i','3i-3j','3j-3k','3k-3l','3l-3m','3m-3n','3n-3o'])
writeFileSync(join(moduleRoot, 'src/migrations', m + '.sql'), readFileSync(new URL('../src/migrations/' + m + '.sql', import.meta.url)));
const old = spawnSync(process.execPath, ['--input-type=module', '-e', `import {Store} from ${JSON.stringify(join(moduleRoot, 'src/store.mjs'))}; try { new Store(${JSON.stringify(root)}); process.exit(9); } catch(e) { if(e.message !== 'schema-mismatch') throw e; }`], { encoding: 'utf8' });
assert.equal(old.status, 0, old.stderr); assert.deepEqual(readFileSync(path), migrated);
});
test('real_runtime_socket_loads_host_lifecycle_and_recovery_keeps_intake_without_capabilities', async t => {
const root = mkdtempSync(join(tmpdir(), 'request-runtime-'));
let runtime;
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } };
const launch = async () => {
runtime = await startBroker({ dataRoot: root, businesses: { demo: business } });
const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
return new Client({ path: runtime.path, cap });
};
let client = await launch(); const source = await client.call('message.send', { to: 'pm', body: intent });
const opened = await client.call('request.open', { request: source.request });
assert.equal(opened.phase, 'selection'); assert.deepEqual(await client.call('request.get', { request: source.request }), opened);
await runtime.close(); runtime = null; client = await launch();
assert.deepEqual(await client.call('request.get', { request: source.request }), opened);
assert.deepEqual(await runtime.requestLifecycle.recover(), [opened]);
});
test('effect_and_request_unique_keys_refuse_replacement_without_explicit_seq', async t => {
const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request });
assert.throws(() => x.store.run(`INSERT OR REPLACE INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body)
SELECT business,request,revision,kind,actor_role,actor_run,at,body FROM request_events`), /append-only/);
for (const table of ['request_snapshots','request_snapshot_events','request_project_events']) {
// Exercise triggers with valid rows; the future producers get immutable storage, no writer API here.
if (table === 'request_snapshots') x.store.run(`INSERT INTO request_snapshots(id,at,business,project,body) VALUES('snapshot',?,'demo','old',?)`, new Date().toISOString(), JSON.stringify({version:1,id:'snapshot',business:'demo',project:'old',origin:'product'}));
else if (table === 'request_snapshot_events') x.store.run(`INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,'demo','old',1,'snapshot','activated','fixture')`, new Date().toISOString());
else x.store.run(`INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,'demo','old',1,'attached','{}')`, new Date().toISOString());
for (const sql of [`UPDATE ${table} SET at=at`, `DELETE FROM ${table}`, `INSERT OR REPLACE INTO ${table} SELECT * FROM ${table}`]) assert.throws(() => x.store.run(sql), /append-only/);
}
const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: {} };
assert.throws(() => x.journal.appendRequest(event), /invalid-request-event/);
});
test('preparation_rechecks_live_pm_claim_before_recording_any_decision', async t => {
let start, release;
const started = new Promise(r => { start = r; }), gate = new Promise(r => { release = r; });
const x = setup(t, { prepared: true, configureHost: host => {
const prepare = host.preparePlans; host.preparePlans = async (...args) => { start(); await gate; return prepare(...args); };
} });
const state = await x.call('pm', 'request.open', { request: x.source.request });
const proposed = x.call('pm', 'request.propose', x.propose(state));
await started; x.call('pm', 'role.release'); release();
await assert.rejects(() => proposed, /not-holder/);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
assert.equal(x.journal.readRequest('demo', state.request).revision, state.revision);
});
test('admission_is_required_and_setup_wait_cannot_claim_ready', async t => {
const x = setup(t, { prepared: true, configureHost: host => { delete host.admitPlan; } });
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const state = await x.call('pm', 'request.propose', x.propose(opened));
assert.equal(state.wait.code, 'request-admission-unavailable'); assert.equal(state.selection, null);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('undeclared_projects_and_changed_existing_tracker_mapping_refuse', async t => {
const x = setup(t, { prepared: true }); const state = await x.call('pm', 'request.open', { request: x.source.request });
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'library' }), /request-project-refused/);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'foreign' }), /request-project-refused/);
const selected = await x.call('pm', 'request.propose', x.propose(state));
x.call('human', 'decision.resolve', { id: selected.selection.decision, choice: 'existing' });
const picker = await x.lifecycle.resume('demo', state.request);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(picker), op: 'bad-picker', source_project: 'old', project: 'old', name: 'Old', tracker_project: 99 }), /request-project-refused/);
assert.equal(x.journal.readRequest('demo', state.request).project, null);
});
test('existing_confirmation_rechecks_exact_current_tracker_association', async t => {
let board = 2;
const x = setup(t, { prepared: true, configureHost: host => { host.listProjects = () => [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: board } }]; } });
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const mode = await x.call('pm', 'request.propose', x.propose(opened));
x.call('human', 'decision.resolve', { id: mode.selection.decision, choice: 'existing' });
const picker = await x.lifecycle.resume('demo', opened.request);
const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'old-binding', source_project: 'old', project: 'old', name: 'Old' });
board = 3;
x.call('human', 'decision.resolve', { id: candidate.selection.decision, choice: 'confirm' });
const stopped = await x.lifecycle.resume('demo', opened.request);
assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'request-existing-project-changed');
assert.deepEqual(stopped.tasks, []);
});
test('journal_refuses_fabricated_original_text_even_on_host_append', t => {
const x = setup(t);
assert.throws(() => x.journal.appendRequest({ business: 'demo', request: x.source.request, expectedRevision: 0, kind: 'opened', actor: { role: 'pm', run: 'pm-run' },
body: { source_message: x.source.id, thread: null, session: null, owner: 'jason', pm: 'pm',
intent: { text: 'fabricated', sha256: hash('fabricated'), source_message: x.source.id, generated: false } } }), /request-origin-refused/);
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
});
test('preparation_can_report_a_precise_setup_wait_without_inventing_a_plan', async t => {
const x = setup(t, { prepared: true, configureHost: host => { host.preparePlans = () => ({ ready: false, code: 'tracker-board-unavailable' }); } });
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const waiting = await x.call('pm', 'request.propose', x.propose(opened));
assert.equal(waiting.wait.code, 'tracker-board-unavailable'); assert.equal(waiting.project, null); assert.equal(waiting.selection, null);
assert.deepEqual(await x.lifecycle.recover(), [waiting]);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('oversize_resource_display_keeps_exact_intake_in_one_explicit_setup_wait', async t => {
const userText = 'Original user intent\n' + 'a'.repeat(6000);
const x = setup(t, { prepared: true, userText });
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const waiting = await x.call('pm', 'request.propose', x.propose(opened));
assert.equal(waiting.wait.code, 'request-plan-display-too-large');
assert.equal(JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='opened'").body).intent.text, userText);
assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('runtime_restart_recovers_a_recorded_autonomous_selection_once_without_bootstrap_effects', async t => {
const x = setup(t, { prepared: true }); const opened = await x.call('pm', 'request.open', { request: x.source.request });
const selection = await x.call('pm', 'request.propose', x.propose(opened));
x.call('human', 'decision.resolve', { id: selection.selection.decision, choice: 'autonomous' });
x.store.close();
const business = { ...x.business, projects: Object.fromEntries(Object.entries(x.business.projects).filter(([, p]) => p.kind !== 'library')) };
const runtime = await startBroker({ dataRoot: x.root, businesses: { demo: business }, requestServices: x.host });
x.runtimeCleanup(runtime);
const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const client = new Client({ path: runtime.path, cap });
const recovered = await client.call('request.get', { request: opened.request });
assert.equal(recovered.phase, 'bootstrap'); assert.equal(recovered.mode, 'autonomous');
assert.equal(recovered.wait.code, 'request-bootstrap-unavailable');
assert.equal(recovered.plan_digest, selection.selection.bindings[0].digest);
assert.deepEqual(await runtime.requestLifecycle.recover(), [recovered]);
assert.deepEqual(recovered.tasks, []); assert.equal(recovered.snapshot, null);
assert.equal(existsSync(join(x.root, 'products')), false);
assert.equal(existsSync(join(x.root, 'projects')), false);
});
// Review r1 I2/I3: a host whose inventory, faults and in-flight calls the test controls.
function controlled(t) {
const control = { board: 2, fault: {}, pause: null };
const hold = async at => {
const pause = control.pause;
if (pause?.at !== at) return;
control.pause = null; pause.start(); await pause.gate;
};
// Arm one pause; the next preparation/admission call of that kind parks until `release()`.
control.pauseAt = at => {
let start, release;
const started = new Promise(r => { start = r; }), gate = new Promise(r => { release = r; });
control.pause = { at, start, gate }; return { started, release };
};
const x = setup(t, { prepared: true, configureHost: host => {
host.listProjects = () => [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: control.board } }];
const { prepareExisting, preparePlans, admitPlan } = host;
host.prepareExisting = async (...args) => {
await hold('prepare');
if (control.fault.prepare) return { ready: false, code: control.fault.prepare };
const plan = prepareExisting(...args);
if (control.fault.big) plan.project_vars = { suites: Array.from({ length: 20 }, (_, i) => String(i).padStart(3, '0') + 'a'.repeat(280)) };
return plan;
};
host.preparePlans = async (...args) => { await hold('prepare'); return preparePlans(...args); };
host.admitPlan = async plan => { await hold('admit'); return control.fault.admit ? { ready: false, code: control.fault.admit } : admitPlan(plan); };
} });
return { x, control };
}
async function reachPicker(x) {
const opened = await x.call('pm', 'request.open', { request: x.source.request });
const mode = await x.call('pm', 'request.propose', x.propose(opened));
x.call('human', 'decision.resolve', { id: mode.selection.decision, choice: 'existing' });
return x.lifecycle.resume('demo', opened.request);
}
const existingArgs = (state, op, extra = {}) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2, ...extra });
const count = (x, sql) => x.store.get(sql).n;
const moveOldMapping = x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, old: { ...x.business.projects.old, repo: join(x.root, 'old-code-moved') } } });
// A fresh-operation retry after the setup correction must land in the existing-project confirmation and bind it.
async function confirmRetry(x, retried) {
assert.equal(retried.route, 'existing'); assert.equal(retried.project, null);
assert.equal(retried.wait.kind, 'decision'); assert.equal(retried.wait.code, 'request-existing-confirmation');
assert.equal(retried.selection.bindings.length, 1); assert.equal(retried.selection.bindings[0].plan.existing, true);
x.call('human', 'decision.resolve', { id: retried.selection.decision, choice: 'confirm' });
const bound = await x.lifecycle.resume('demo', retried.request);
assert.equal(bound.project, 'old'); assert.equal(bound.phase, 'discovery'); assert.equal(bound.wait.code, 'request-attachment-unavailable');
assert.deepEqual(bound.tasks, []); assert.equal(bound.snapshot, null);
}
for (const w of [
{ name: 'preparation', code: 'tracker-temporarily-unavailable', arm: c => { c.fault.prepare = 'tracker-temporarily-unavailable'; } },
{ name: 'admission', code: 'organization-stale', arm: c => { c.fault.admit = 'organization-stale'; } },
{ name: 'oversized_display', code: 'request-plan-display-too-large', arm: c => { c.fault.big = true; } },
]) test(`existing_route_survives_a_${w.name}_wait_and_a_fresh_operation_retry_resumes_the_picker`, async t => {
const { x, control } = controlled(t), picker = await reachPicker(x);
assert.equal(picker.route, 'existing'); assert.equal(picker.wait.code, 'request-existing-project');
w.arm(control);
const waiting = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
assert.equal(waiting.wait.kind, 'setup'); assert.equal(waiting.wait.code, w.code);
assert.equal(waiting.route, 'existing'); assert.equal(waiting.selection, null); assert.equal(waiting.project, null);
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), 1, 'only the mode decision exists');
control.fault = {}; // the capability is restored
const retried = await x.call('pm', 'request.propose', existingArgs(waiting, 'existing-2'));
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), 2);
// The earlier operation stays one immutable wait event; replaying it records nothing new.
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE json_extract(body,'$.op')='existing-1'"), 1);
const events = count(x, 'SELECT count(*) n FROM request_events');
assert.deepEqual(await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1')), x.journal.readRequest('demo', picker.request));
assert.equal(count(x, 'SELECT count(*) n FROM request_events'), events);
await confirmRetry(x, retried);
});
test('existing_route_survives_a_changed_association_wait_and_a_fresh_operation_retry_resumes_the_picker', async t => {
const { x, control } = controlled(t), picker = await reachPicker(x);
const candidate = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1'));
control.board = 3;
x.call('human', 'decision.resolve', { id: candidate.selection.decision, choice: 'confirm' });
const stopped = await x.lifecycle.resume('demo', picker.request);
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, 'request-existing-project-changed');
assert.equal(stopped.route, 'existing'); assert.equal(stopped.project, null);
const retried = await x.call('pm', 'request.propose', existingArgs(stopped, 'existing-2', { tracker_project: 3 }));
assert.equal(retried.selection.bindings[0].plan.tracker.project, 3);
await confirmRetry(x, retried);
});
test('new_project_route_still_refuses_an_existing_project_name', async t => {
const { x } = controlled(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
assert.equal(opened.route, null);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(opened), project: 'old' }), /request-project-collision/);
});
// Review r1 I3: the association changes while preparation or admission is in flight.
for (const c of [
{ name: 'existing_board', route: 'existing', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
{ name: 'existing_mapping', route: 'existing', code: 'request-plan-binding-refused', change: moveOldMapping },
{ name: 'new_project_collision', route: 'mode', code: 'request-project-collision',
change: x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, timer: { root: join(x.root, 'timer-records') } } }) },
]) test(`selection_is_not_confirmed_when_${c.name}_changes_during_admission`, async t => {
const { x, control } = controlled(t);
let state, choice;
if (c.route === 'existing') {
state = await x.call('pm', 'request.propose', existingArgs(await reachPicker(x), 'existing-1')); choice = 'confirm';
} else {
state = await x.call('pm', 'request.propose', x.propose(await x.call('pm', 'request.open', { request: x.source.request }))); choice = 'autonomous';
}
x.call('human', 'decision.resolve', { id: state.selection.decision, choice });
const gate = control.pauseAt('admit'), resuming = x.lifecycle.resume('demo', state.request);
await gate.started; await c.change(x, control); gate.release();
const stopped = await resuming;
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.confirmed'"), 0);
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, c.code);
assert.equal(stopped.project, null); assert.equal(stopped.mode, null); assert.equal(stopped.plan_digest, null);
assert.equal(stopped.phase, 'selection'); assert.deepEqual(stopped.tasks, []);
assert.deepEqual(await x.lifecycle.resume('demo', state.request), stopped);
});
for (const c of [
{ name: 'existing_board_during_preparation', route: 'existing', at: 'prepare', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
{ name: 'existing_board_during_admission', route: 'existing', at: 'admit', code: 'request-existing-project-changed', change: (x, control) => { control.board = 3; } },
{ name: 'existing_mapping_during_admission', route: 'existing', at: 'admit', code: 'request-plan-binding-refused', change: moveOldMapping },
{ name: 'new_project_collision_during_admission', route: 'mode', at: 'admit', code: 'request-project-collision',
change: x => x.b.reloadBusiness('demo', { ...x.business, projects: { ...x.business.projects, timer: { root: join(x.root, 'timer-records') } } }) },
]) test(`proposal_records_a_stale_wait_not_a_decision_when_${c.name}`, async t => {
const { x, control } = controlled(t);
const base = c.route === 'existing' ? await reachPicker(x) : await x.call('pm', 'request.open', { request: x.source.request });
const args = c.route === 'existing' ? existingArgs(base, 'late-1') : x.propose(base);
const decisions = count(x, 'SELECT count(*) n FROM decisions'), proposed = count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.proposed'");
const gate = control.pauseAt(c.at), proposing = x.call('pm', 'request.propose', args);
await gate.started; await c.change(x, control); gate.release();
const stopped = await proposing;
assert.equal(count(x, 'SELECT count(*) n FROM decisions'), decisions, 'no decision for a stale plan');
assert.equal(count(x, "SELECT count(*) n FROM request_events WHERE kind='selection.proposed'"), proposed);
assert.equal(stopped.wait.kind, 'setup'); assert.equal(stopped.wait.code, c.code);
assert.equal(stopped.selection, null); assert.equal(stopped.project, null); assert.equal(stopped.phase, 'selection');
assert.equal(stopped.route, c.route === 'existing' ? 'existing' : null);
});
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequestLifecycle } from '../src/request-lifecycle.mjs';
function recoveryFixture({ retained, target = 0, signalAt = null }) {
const requests = Array.from({ length: retained }, (_, i) => ({
business: 'demo', request: `r${i}`, revision: 1, source_message: 'source',
phase: i === target ? 'implementation' : 'cancelled', pm: 'pm', project: 'factory',
wait: null, question: null, plan_digest: 'plan',
selection: { bindings: [{ digest: 'plan', plan: { staff: { coder: 'coder', reviewer: 'reviewer', chief: 'chief' } } }] },
spec_commit: null, candidate: null, snapshot: 'snapshot', tasks: ['vikunja:2/1'], evidence: [],
}));
const receipts = [], messages = [], inspected = new Set();
let lifecycle, evidence = false, signalled = false;
const journal = {
listRequests: () => requests.map(({ business, request }) => ({ business, request })),
readRequest(business, request) {
inspected.add(request);
if (!signalled && request === `r${signalAt}`) {
signalled = true; evidence = true;
lifecycle.recover({ limit: 64 });
}
return structuredClone(requests.find(row => row.business === business && row.request === request));
},
effects: (business, request) => receipts.filter(row => row.business === business && row.request === request),
transaction: fn => fn(),
prepareEffect(row) {
const old = receipts.find(r => r.business === row.business && r.request === row.request && r.op === row.op && r.stage === row.stage);
if (old) return old;
receipts.push(row); return row;
},
finishEffect(row) { receipts.push(row); return row; },
};
const services = {
origin() {}, authorize() {}, raiseDecision() {}, readDecision() {}, project() {},
continuationContext: (_, role) => ({ run: `${role}-live`, prerequisite: 'stable' }),
continuationProgress: state => state.request === `r${target}` && evidence
? { phase: 'review', tasks: state.tasks, evidence: ['validated-push', 'validated-review'] } : null,
continuationMessage(state, role, body) {
const id = `m${messages.length}`; messages.push({ id, request: state.request, role, body }); return id;
},
};
lifecycle = createRequestLifecycle({ journal, services });
return { lifecycle, inspected, wakes: () => messages.filter(m => m.request === `r${target}` && m.role === 'pm').length };
}
for (const [retained, target, signalAt, prime] of [[128, 0, 1, false], [257, 64, 65, true]]) {
test(`overlapping evidence revisits its request across bounded recovery batches (${retained} retained rows)`, async t => {
const f = recoveryFixture({ retained, target, signalAt }); t.after(() => f.lifecycle.close());
if (prime) await f.lifecycle.recover({ limit: 64 });
await f.lifecycle.recover({ limit: 64 });
assert.equal(f.wakes(), 2, 'the joined evidence-triggered recovery wakes the live PM without a later timer sweep');
for (let i = 0; i < 6; i++) await f.lifecycle.recover({ limit: 64 });
assert.equal(f.wakes(), 2, 'unchanged evidence remains durably deduplicated');
});
}
test('quiescent recovery keeps its per-call batch bound and advances the cursor', async t => {
const f = recoveryFixture({ retained: 128 }); t.after(() => f.lifecycle.close());
await f.lifecycle.recover({ limit: 64 });
assert.equal(f.inspected.size, 64);
await f.lifecycle.recover({ limit: 64 });
assert.equal(f.inspected.size, 128);
assert.equal(f.wakes(), 1);
});
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { requestWorkspace } from '../src/request-workspace.mjs';
import { requestFixture } from './request-fixture.mjs';
function fixture(t) {
const dataRoot = mkdtempSync(join(tmpdir(), 'workspace-request-')); t.after(() => rmSync(dataRoot, { recursive: true, force: true }));
const checkout = join(dataRoot, 'workspaces', 'demo', 'builder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.txt'), 'not read');
const state = { business: 'demo', request: 'req1', pm: 'pm', project: 'timer', snapshot: 'snap1', tasks: ['vikunja:2/41'], plan_digest: 'plan', selection: { bindings: [{ digest: 'plan', plan: { business: 'demo', request: 'req1', project: 'timer', staff: { pm: 'pm', coder: 'builder' } } }] } };
const events = [
{ kind: 'task.created', actor_role: 'pm', subject: state.tasks[0], body: { request: 'req1', project_key: 'timer', requirement_snapshot: 'snap1' } },
{ kind: 'task.assigned', actor_role: 'pm', subject: state.tasks[0], body: { role: 'builder' } },
{ kind: 'session.launched', actor_role: 'builder', subject: 'run1', body: {} },
];
const dir = join(dataRoot, 'launches', 'demo', 'run1', 'bundle'); mkdirSync(dir, { recursive: true });
const proof = (outcome = 'on branch main at commit abc when this session started') => {
const prompt = `- Project clones: timer at ${checkout}, ${outcome}.\n`;
writeFileSync(join(dir, 'prompt.md'), prompt);
writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'builder', run: 'run1', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
};
proof(); return { dataRoot, checkout, state, events, proof, dir };
}
test('resolves only a confirmed request and host-proven coder checkout', t => {
const f = fixture(t), read = () => requestWorkspace(f);
assert.deepEqual(read().entries, [{ name: 'draft.txt' }]);
f.state.request = 'other'; assert.equal(read().available, false); f.state.request = '../req1'; assert.equal(read().available, false); f.state.request = 'req1';
f.events[1].body.role = 'other'; assert.equal(read().available, false); f.events[1].body.role = 'builder';
f.proof('not updated at this launch (not-a-clone)'); assert.equal(read().available, false);
f.proof(); writeFileSync(join(f.dir, 'prompt.md'), 'tampered'); assert.equal(read().available, false);
f.events.pop(); assert.equal(read().available, false);
});
test('console capability is required before the workspace read and other requests are refused', async t => {
const f = await requestFixture(t);
const args = { request: f.request };
const result = await f.call('console', 'request.workspace.console', args);
assert.equal(result.available, false);
for (const principal of ['human', 'pm', 'cto', 'coder', 'reader'])
await assert.rejects(async () => f.call(principal, 'request.workspace.console', args), /human-required|read-only|tester-refused/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: 'someone-else' }), /request-not-found/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: '../other' }), /invalid-request|request-not-found/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { ...args, path: '/etc' }), /invalid-request/);
assert.throws(() => f.bus.broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'test', iss: 'test', sub: 'other' } }), /human-required/);
});
test('real broker derives a workspace only after request and coder assignment evidence', async t => {
const f = await requestFixture(t), sub = await f.submit(); f.approve(sub); await f.activate(sub);
const { createDigest, createMarker } = await import('../src/broker.mjs');
const state = f.journal.readRequest('demo', f.request), task = 'vikunja:2/41';
const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: state.snapshot,
project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] };
prepared.body = { title: 'Timer', description: createMarker(prepared) }; prepared.body_digest = createDigest(prepared.body);
const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared);
let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 };
const snapshot = () => ({ task_ref: task, fields, digest: createDigest(fields), updated: new Date().toISOString() });
f.bus.broker.recordRecoveredCreate({ receipt, task_ref: task, snapshot: { ...snapshot(), creator: 3 } });
fields = { ...fields, assignees: [5] };
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task, body: { role: 'coder', previous: [] } }] });
await f.call('pm', 'request.advance', { request: f.request, revision: state.revision, op: 'start' });
const checkout = join(f.f.dataRoot, 'workspaces', 'demo', 'coder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.js'), 'no contents read');
const bundle = join(f.f.dataRoot, 'launches', 'demo', 'coder-run', 'bundle'); mkdirSync(bundle, { recursive: true });
const prompt = `- Project clones: timer at ${checkout}, on branch main at commit abc when this session started.\n`;
writeFileSync(join(bundle, 'prompt.md'), prompt);
writeFileSync(join(bundle, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'coder', run: 'coder-run', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
const result = await f.call('console', 'request.workspace.console', { request: f.request });
assert.equal(result.available, true); assert.deepEqual(result.entries, [{ name: 'draft.js' }]);
});
test('a different request owner is refused before any workspace access', async t => {
const { Store } = await import('../src/store.mjs'); const { Broker } = await import('../src/broker.mjs');
const root = mkdtempSync(join(tmpdir(), 'workspace-owner-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const roles = { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } };
let reads = 0;
const state = { business: 'demo', request: 'r1', owner: 'someone-else', pm: 'pm', revision: 1 };
const lifecycle = { get: async () => state };
for (const key of ['open', 'projects', 'propose', 'question', 'answer', 'attach', 'cancel', 'modeConsole', 'draftConsole', 'resume', 'recover']) lifecycle[key] = () => {};
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', roles, arbiters: { technical: 'pm', delivery: 'pm' }, projects: {} } }, requestLifecycle: () => lifecycle, workspaceRead: () => { reads++; return {}; } });
const cap = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
await assert.rejects(async () => broker.request(cap, { verb: 'request.workspace.console', args: { request: 'r1' } }), /request-access-refused/);
assert.equal(reads, 0);
});
+8
View File
@@ -236,3 +236,11 @@ test('a refusal the host decided is filed on the task it names, and only a task
[[TASK, 'non-fast-forward'], [null, 'invalid-request'], [null, 'invalid-request']],
);
});
test('unrelated generic review keeps free-text contract and verdict', t => {
const { call, push } = setup(t); push(A);
const review = call('coder', 'review.request', { task_ref: TASK, commit: A, to: 'reviewer' });
const message = call('reviewer', 'message.receive', {}).find(m => m.id === review.message);
assert.doesNotMatch(message.body, /verification_record|raw JSON/);
assert.ok(call('reviewer', 'review.verdict', { task_ref: TASK, request: review.request, verdict: 'approve', body: 'Tests passed.' }).verdict);
});
+5 -2
View File
@@ -524,7 +524,10 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
assert.equal(count('table') - 1, 25);
assert.equal(count('trigger'), 96);
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
assert.equal(count('table') - 1, 31);
assert.equal(count('trigger'), 119);
assert.equal(count('view'), 5);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
const A = 'a'.repeat(40);
const push = (f, task, project) => ({ task_ref: task.task_ref, project, branch: 'feat', ref: 'refs/heads/work', commit: A, previous: null });
test('checkPush binds a product before bytes and recordPush repeats the project check', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args);
const b = f.bus.broker;
for (const project of ['stack', undefined, 'missing']) assert.throws(() => b.checkPush(f.caps.coder, task.task_ref, project), { code: 'task-project-refused' });
b.checkPush(f.caps.coder, task.task_ref, 'timer');
assert.throws(() => b.recordPush(f.caps.coder, push(f, task, 'stack')), { code: 'task-project-refused' });
b.recordPush(f.caps.coder, push(f, task, 'timer'));
const review = f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' });
assert.equal(b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: review.request }).project, 'timer');
await f.restart();
assert.throws(() => f.bus.checkPush(f.caps.coder, task.task_ref, 'stack'), { code: 'task-project-refused' });
f.bus.checkPush(f.caps.coder, task.task_ref, 'timer');
});
test('review, review export and remote export reject old cross-project evidence for a scoped task', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args), b = f.bus.broker;
// Host-written historical evidence can predate scope admission; consumers must still check it.
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(f.f.dataRoot + '/bus/bus.sqlite');
db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-push',?,'demo','coder','coder-run','action.allowed',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ action: 'git.push.working', project: 'stack', commit: A, ref: 'refs/heads/work' }));
db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-review',?,'demo','coder','coder-run','review.requested',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ to: 'reviewer', project: 'stack', commit: A, ref: 'refs/heads/work' }));
db.close();
assert.throws(() => f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' }), { code: 'task-project-refused' });
assert.throws(() => b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: 'old-review' }), { code: 'task-project-refused' });
assert.throws(() => b.checkPublish(f.caps.coder, { task_ref: task.task_ref, project: 'stack', branch: 'feat' }), { code: 'task-project-refused' });
});
test('legacy recorded tasks still admit a push without inventing scope', async t => {
const f = await scopedFixture(t), task = { task_ref: 'vikunja:1/789' }, b = f.bus.broker;
b.recordTask({ cap: f.caps.pm, business: 'demo', events: [{ kind: 'task.created', subject: task.task_ref, body: { request: f.instruction(), requirement: 'REQ-TASK-1' } }] });
b.checkPush(f.caps.coder, task.task_ref);
b.recordPush(f.caps.coder, push(f, task, 'stack'));
assert.equal(f.call('coder', 'trail', { subject: task.task_ref }).find(e => e.body?.action === 'git.push.working').body.scope, undefined);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
test('any archived chat binding fences a shared repository until all are unarchived',async t=>{
const f=await requestFixture(t);
const a=f.call('human','project.create',{name:'Factory one',repo:'stack'}).id;
const b=f.call('human','project.create',{name:'Factory two',repo:'stack'}).id;
const ready=()=>f.bus.admission({business:'demo',project:'stack'});
assert.equal(ready().ready,true);
f.call('human','project.archive',{project:a});assert.equal(ready().code,'project-archived');
assert.equal(f.bus.admission({business:'demo',project:'timer'}).ready,true);
f.call('human','project.archive',{project:b});
f.call('human','project.unarchive',{project:a});assert.equal(ready().code,'project-archived');
f.call('human','project.unarchive',{project:b});assert.equal(ready().ready,true);
console.log('SHARED_REPO_CONTROL',JSON.stringify({oneArchived:'project-archived',oneStillArchived:'project-archived',allLive:ready(),unrelated:f.bus.admission({business:'demo',project:'timer'})}));
});
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3n ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3n');
assert.equal(version(path), '3t');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+2
View File
@@ -218,6 +218,8 @@ test('REQ-MSG-1: a message to or from the assistant names its thread, or is refu
// Accepted messages: the user's, the assistant's to the user and the chief, a decision close.
const asked = call('human', 'thread.send', { thread: A, body: 'look at the build' });
const toUser = call('a', 'message.send', { to: 'human', body: 'on it', in_reply_to: asked.id });
assert.equal(toUser.request, asked.request);
assert.ok(toUser.request, 'threaded reply retains the original request');
const toChief = call('b', 'message.send', { to: 'chief', body: 'need a coder', class: 'REQUEST' });
assert.equal(threadOf(asked.id), A);
assert.equal(threadOf(toUser.id), A);
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+13
View File
@@ -335,6 +335,13 @@ outside the vocabulary. `launch` is set exactly when `classify(record,
`role.launch` for the launcher, `launch` is null. A launcher should
still ask `classify`, not read `launch` alone.
An assistant also receives `coordinator`: the validated business's `launch.by`
as `instance`, and that instance's configured `identity.name` as `name` (or
null). With no launch block, `coordinator` is null. This routing context is
included in the resolved digest and the generated session prompt; it does not
grant authority. The name is descriptive, not a broker display-name alias;
the assistant addresses messages to the exact instance key.
Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required
file missing). The CLI adds 3 for a system config problem and 4 for usage.
@@ -364,6 +371,12 @@ torn evidence refuses organization writes. Bus startup, Settings reads and
launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must
confirm that its old writer is gone before clearing it; the host does not
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
The narrow `setProjectRemote` host method can fill only a missing remote on an
existing software project. It checks the actor, revision, normalized HTTPS
repository URL and the exact document delta under the same lock. It refuses
libraries, credentials in URLs, replacement and clearing. It does not contact
the remote or grant publishing authority. The Console setter is currently
closed until session liveness can be verified at the commit boundary.
`launch-settings.mjs` restricts human edits to limits, model table, instance
model selections, routes and assistant settings, and checks every route before publication. `plugin-settings.mjs`
is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic`
+111 -11
View File
@@ -10,6 +10,8 @@ import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -186,11 +188,20 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
fail("organization-uncertain", "stale writer lock: confirm the old writer is gone before removing it");
}
try {
return fn({ home, parent });
} finally {
const release = () => {
rmSync(lock, { recursive: true });
syncDir(home);
};
let pending = false;
try {
const out = fn({ home, parent });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
pending = true;
return Promise.resolve(out).finally(release);
}
return out;
} finally {
if (!pending) release();
}
}
@@ -244,7 +255,10 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
function write(id, document, { revision, actor, detail, context, beforeWrite = null }) {
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
@@ -256,10 +270,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
if (typeof document === "function") document = document(old);
guard(old, document);
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
@@ -268,8 +280,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
guard(old, written);
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
@@ -311,6 +322,95 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
// C2-04 M9: every organization setter preserves the whole project mapping, including library
// playgrounds and hybrid universes.
const projectsProtected = (old, document) => {
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, guard: projectsProtected });
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
// no other change. It compares both the document it built and the bytes it will publish. A stale
// revision refuses revision-conflict: the owner confirms a fresh preview, never a silent rebase.
function addProject(id, { revision, project, entry, receipt }, actor) {
const guard = (old, document) => {
const problem = additionProblem({ old, next: document, project, entry, receipt });
if (problem) fail("organization-project-add-refused", problem);
};
return commit(id, old => ({ ...old, projects: { ...old.projects, [project]: entry } }), {
revision, actor, guard,
detail: { verb: ADDITION_VERB, request: receipt?.request, plan_digest: receipt?.plan_digest, project },
});
}
// The only exception to the projects fence for an existing software mapping.
// Build from the locked document and check both the candidate and serialized bytes.
function setProjectRemote(id, { revision, project, remote }, actor) {
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
const problem = remoteProblem(old, document, project, remote);
if (problem) fail(problem);
};
return commit(id, old => {
remoteTarget(old, project, remote);
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
function hold(id, fn) {
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const { document, revision } = read(id);
const out = fn({ document, revision });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
out.catch?.(() => {});
fail("organization-hold-sync", "a held check must be synchronous");
}
return out;
});
}
// Like hold, but the lock stays on disk until an asynchronous effect settles, including on failure.
// The effect must not call an organization writer: it would find this same lock and refuse busy.
function holdAsync(id, fn) {
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
return fn(read(id));
});
}
// The committed addition of this request and plan, if there is one. A prepared receipt whose
// `after` is the file's current hash counts: the rename landed and the commit line did not.
function findAddition(id, { request, plan_digest, project }) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const last = new Map();
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
const hash = sha256(regular(businessFilePath(id, dir)));
for (const e of last.values()) {
const d = e.detail;
if (d?.verb !== ADDITION_VERB || d.request !== request || d.plan_digest !== plan_digest || d.project !== project) continue;
if (e.state === "committed" || (e.state === "prepared" && e.after === hash))
return { after: e.after, before: e.before, transaction: e.transaction };
}
return null;
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
@@ -328,5 +428,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
@@ -0,0 +1,79 @@
// Lifecycle Task 2 (#797 / Gitea #1839): what an exact project addition looks like. The organization
// writer's addProject calls additionProblem on the document it is about to publish, and again on the
// bytes it will write. The bootstrap executor names the paths it reserves with managedPaths, so the
// writer and the executor cannot disagree about the layout.
import { lstatSync, readFileSync } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
import { canonicalJson } from "./util.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
export const ADDITION_VERB = "project.add";
const RECEIPT_KEYS = ["data_root", "plan_digest", "project", "request"];
const ENTRY_KEYS = ["remote", "repo", "root"];
const plain = x => x !== null && typeof x === "object" && !Array.isArray(x);
const REQUEST_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
// A managed product's code checkout and its records repository, from the data root alone.
export function managedPaths({ dataRoot, business, project }) {
return { root: join(dataRoot, "products", business, project, "code"), repo: join(dataRoot, "projects", business, project) };
}
// null when `next` is `old` plus exactly the planned `projects[project]` entry, else the reason.
export function additionProblem({ old, next, project, entry, receipt }) {
if (typeof project !== "string" || !ID_PATTERN.test(project)) return "project key is not an id";
if (!plain(receipt) || Object.keys(receipt).sort().join() !== RECEIPT_KEYS.join()) return "receipt has the wrong keys";
if (typeof receipt.request !== "string" || receipt.request.length > 160 || !REQUEST_ID.test(receipt.request)) return "receipt request is not an id";
if (typeof receipt.plan_digest !== "string" || !/^[a-f0-9]{64}$/.test(receipt.plan_digest)) return "receipt plan digest is not a digest";
if (receipt.project !== project) return "receipt names another project";
if (typeof receipt.data_root !== "string" || !isAbsolute(receipt.data_root) || normalize(receipt.data_root) !== receipt.data_root) return "receipt data root is not absolute";
if (!plain(entry) || Object.keys(entry).some(k => !ENTRY_KEYS.includes(k))) return "entry holds more than root, repo and remote";
if (typeof entry.root !== "string" || typeof entry.repo !== "string" || ("remote" in entry && typeof entry.remote !== "string")) return "entry root and repo are required strings";
if (!plain(old?.projects) || !plain(next?.projects)) return "business has no project mapping";
const expected = managedPaths({ dataRoot: receipt.data_root, business: old.id, project });
if (entry.root !== expected.root || entry.repo !== expected.repo) return "entry is not the managed product layout";
if (Object.hasOwn(old.projects, project)) return "the business already declares this project";
if (canonicalJson(next.projects) !== canonicalJson({ ...old.projects, [project]: entry })) return "mapping differs from the old mapping plus the entry";
const { projects: _next, ...rest } = next, { projects: _old, ...was } = old;
if (canonicalJson(rest) !== canonicalJson(was)) return "a key outside projects changed";
return null;
}
// The product marker, <dataRoot>/products/<business>/<project>/bootstrap.json. The bootstrap executor writes it
// `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the
// bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can
// tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the
// committed activate receipt (#802).
const MARKER_STATES = ["reserved", "complete"];
export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json");
export function readMarker(file) {
let bytes;
try {
if (!lstatSync(file).isFile()) return { state: "invalid" };
bytes = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") return null;
return { state: "invalid" };
}
try {
const m = JSON.parse(bytes);
const keys = Object.keys(m ?? {}).sort().join();
if (plain(m) && keys === "plan_digest,project,request,state,version" && m.version === 1 && MARKER_STATES.includes(m.state) &&
typeof m.project === "string" && typeof m.request === "string" && /^[a-f0-9]{64}$/.test(m.plan_digest)) return m;
} catch { /* falls through */ }
return { state: "invalid" };
}
export const markerBytes = ({ state, request, plan_digest, project }) =>
JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n";
// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap
// published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry
// there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete`
// marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable.
// Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and
// the launcher asks the bus before it clones one.
export function managedState({ dataRoot, business, project, entry }) {
const here = managedPaths({ dataRoot, business, project });
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
const marker = readMarker(markerFile({ dataRoot, business, project }));
return marker?.state === "complete" && marker.project === project ? "complete" : "reserved";
}
+31
View File
@@ -0,0 +1,31 @@
// Narrow, offline validation for a first repository URL. Never include the supplied
// URL in a refusal: it may contain credentials.
import { parseGiteaUrl } from './vocabulary.mjs';
import { canonicalJson } from './util.mjs';
export function remoteTarget(document, project, remote) {
const reject = code => { const error = new Error(code); error.code = code; throw error; };
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) reject('project-remote-unknown');
const entry = document.projects[project];
if (entry.kind === 'library') reject('project-remote-library');
if (Object.hasOwn(entry, 'remote')) reject('project-remote-configured');
if (!parseGiteaUrl(remote, { repo: true }).url) reject('project-remote-invalid');
}
export function remoteProblem(old, next, project, remote) {
try { remoteTarget(old, project, remote); } catch (error) { return error.code; }
const expected = { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
return canonicalJson(expected) === canonicalJson(next) ? null : 'project-remote-protected';
}
export function getProjectRemote(writer, business, project) {
const { document, revision } = writer.read(business);
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) {
const error = new Error('project-remote-unknown'); error.code = error.message; throw error;
}
const entry = document.projects[project];
if (entry.kind === 'library') {
const error = new Error('project-remote-library'); error.code = error.message; throw error;
}
return { project, remote: entry.remote ?? null, revision, firstSaveAllowed: !Object.hasOwn(entry, 'remote') };
}
+7
View File
@@ -92,6 +92,13 @@ export function resolveInstance({ system, business, project = null, instance })
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
// #797 relay R2: an owner-started assistant cannot infer its coordinator
// from launchedBy or running claims. Only the validated business chooses
// this address; it grants no launch authority or messaging exception.
...(definition.name === "assistant" ? { coordinator: business.launch ? {
instance: business.launch.by,
name: business.roles[business.launch.by].identity?.name ?? null,
} : null } : {}),
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
webSource: business.plugins?.["search-source"] ?? null,
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
+1
View File
@@ -10,6 +10,7 @@
// effect and isn't listed.
export const ACTIONS = Object.freeze([
"task.create",
"project.bootstrap",
"task.assign",
"task.schedule",
"task.update.assigned",
@@ -153,6 +153,9 @@ test("the contract passes work to the chief of staff and directs no one", () =>
assert.match(text, /don't direct\s+other agents/);
assert.match(text, /create or assign\s+tasks/);
assert.match(text, /launch\s+sessions/);
assert.match(text, /exact instance key in the generated `Configured coordinator`\s+line under `This session`/);
assert.match(text, /Use that key as send_message\.to for requests\s+and rulings/);
assert.doesNotMatch(text, /addressed to the `chief-of-staff` role/);
// Round 1 F1: rulings go to the chief of staff, never to the role they concern (REQ-ASSIST-1, REQ-ASSIST-2).
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/);
assert.match(text, /Don't\s+send\s+it\s+to\s+that\s+role\s+yourself/);
@@ -70,6 +70,8 @@ test("row 323: committed Case 1 document loads unchanged through the business lo
assert.deepEqual(b.projects, { "stack": { root: "/srv/mosaic/src/mosaic-stack" } });
assert.deepEqual(Object.keys(b.roles), ["chief", "assistant"]);
assert.deepEqual(b.plugins.harness, ["claude-code"]);
assert.deepEqual(document().plugins.channel, ["cli", "webui"]);
assert.deepEqual(b.plugins.channel, ["cli", "webui"]);
assert.equal(b.launch.by, "chief");
assert.deepEqual(b.launch.instances, ["assistant"]);
assert.deepEqual(b.arbiters, { delivery: "chief", technical: "chief" });
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
"git.push.protected", "deploy", "git.push.remote", "host.sudo"];
"git.push.protected", "deploy", "git.push.remote", "host.sudo", "project.bootstrap"];
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => {
const dir = tmp();
@@ -0,0 +1,180 @@
// Lifecycle Task 2 (#797 / Gitea #1839): the one narrow organization-writer method that adds a
// bootstrapped project's mapping. Every ordinary writer keeps refusing project changes.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter } from '../src/organization.mjs';
import { managedState, markerBytes, markerFile } from '../src/project-bootstrap.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
const DIGEST = 'a'.repeat(64);
function fixture(t, { barrier } = {}) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
doc.projects = { books: mappingEntry(), ...doc.projects };
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
const dataRoot = join(root, 'data');
const writer = organizationWriter({ dir, rolesDir: rolesCopy(root), ...(barrier ? { barrier: step => barrier(step) } : {}) });
const entry = { root: join(dataRoot, 'products/acme/timer/code'), repo: join(dataRoot, 'projects/acme/timer') };
const receipt = { request: 'req-1', plan_digest: DIGEST, project: 'timer', data_root: dataRoot };
const add = (over = {}) => writer.addProject('acme', { revision: writer.read('acme').revision, project: 'timer', entry, receipt, ...over }, 'jason');
return { root, dataRoot, file, writer, entry, receipt, add, log: writer.logFile('acme') };
}
const bytes = f => ({ file: readFileSync(f.file), log: (() => { try { return readFileSync(f.log); } catch { return null; } })() });
const untouched = (f, before) => {
assert.deepEqual(readFileSync(f.file), before.file);
assert.deepEqual((() => { try { return readFileSync(f.log); } catch { return null; } })(), before.log);
};
test('add_project_publishes_exactly_one_mapping_with_a_matching_receipt', t => {
const f = fixture(t), old = f.writer.read('acme');
const out = f.add();
const now = f.writer.read('acme');
assert.equal(out.revision, now.revision);
assert.deepEqual(Object.keys(now.document.projects).sort(), ['books', 'stack', 'timer']);
assert.deepEqual(now.document.projects.timer, f.entry);
assert.deepEqual(now.document.projects.stack, old.document.projects.stack);
assert.deepEqual(now.document.projects.books, old.document.projects.books);
const rest = structuredClone(now.document), was = structuredClone(old.document);
delete rest.projects; delete was.projects;
assert.deepEqual(rest, was, 'nothing outside projects changed');
const last = JSON.parse(readFileSync(f.log, 'utf8').trim().split('\n').at(-1));
assert.equal(last.state, 'committed'); assert.equal(last.actor, 'jason'); assert.equal(last.before, old.revision); assert.equal(last.after, now.revision);
assert.deepEqual(last.changes.map(c => c.path), ['projects.timer']);
assert.deepEqual(last.detail, { verb: 'project.add', request: 'req-1', plan_digest: DIGEST, project: 'timer' });
assert.deepEqual(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }),
{ after: now.revision, before: old.revision, transaction: last.transaction });
assert.equal(f.writer.findAddition('acme', { request: 'req-1', plan_digest: 'b'.repeat(64), project: 'timer' }), null);
assert.equal(f.writer.findAddition('acme', { request: 'req-2', plan_digest: DIGEST, project: 'timer' }), null);
});
test('add_project_refuses_everything_but_the_exact_addition_and_changes_no_byte', t => {
const f = fixture(t), before = bytes(f);
const refuse = (what, over, code = 'organization-project-add-refused') => {
assert.throws(() => f.add(over), { code }, what); untouched(f, before);
};
refuse('replacement', { project: 'stack', receipt: { ...f.receipt, project: 'stack' }, entry: { ...f.entry } });
refuse('library name', { project: 'books', receipt: { ...f.receipt, project: 'books' } });
refuse('library entry shape', { entry: { ...mappingEntry() } });
refuse('extra entry key', { entry: { ...f.entry, kind: 'library' } });
refuse('unrelated entry key', { entry: { ...f.entry, tracker: { project: 3 } } });
refuse('entry root off the product layout', { entry: { ...f.entry, root: join(f.root, 'elsewhere') } });
refuse('entry repo off the records layout', { entry: { ...f.entry, repo: join(f.root, 'elsewhere') } });
refuse('entry in another business area', { entry: { root: join(f.dataRoot, 'products/other/timer/code'), repo: join(f.dataRoot, 'projects/other/timer') } });
refuse('receipt names another project', { receipt: { ...f.receipt, project: 'other' } });
refuse('receipt data root differs', { receipt: { ...f.receipt, data_root: join(f.root, 'other-data') } });
refuse('receipt digest is not a digest', { receipt: { ...f.receipt, plan_digest: 'nope' } });
refuse('receipt carries extra keys', { receipt: { ...f.receipt, extra: 1 } });
refuse('receipt lacks the request', { receipt: { project: 'timer', plan_digest: DIGEST, data_root: f.dataRoot } });
refuse('relative project key', { project: '../timer', receipt: { ...f.receipt, project: '../timer' } });
assert.throws(() => f.add({ revision: 'stale' }), { code: 'revision-conflict' }); untouched(f, before);
assert.throws(() => f.writer.addProject('acme', { revision: f.writer.read('acme').revision, project: 'timer', entry: f.entry, receipt: f.receipt }, ''), /authenticated actor/);
untouched(f, before);
});
test('add_project_checks_what_lands_on_disk_not_only_the_input', t => {
const f = fixture(t), before = bytes(f);
const entry = { ...f.entry };
Object.defineProperty(entry, 'toJSON', { value: () => ({ ...f.entry, root: join(f.root, 'serialized-elsewhere') }) });
assert.throws(() => f.add({ entry }), { code: 'organization-project-add-refused' });
untouched(f, before);
});
test('add_project_twice_is_refused_and_the_first_receipt_stays_findable', t => {
const f = fixture(t); f.add();
const after = bytes(f);
assert.throws(() => f.add(), { code: 'organization-project-add-refused' });
untouched(f, after);
assert.ok(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
});
test('ordinary_writers_still_refuse_any_project_change_even_the_exact_addition', t => {
const f = fixture(t), before = bytes(f);
const change = fn => {
const next = structuredClone(f.writer.read('acme').document); fn(next);
assert.throws(() => f.writer.write('acme', next, { revision: f.writer.read('acme').revision, actor: 'jason' }), { code: 'organization-projects-protected' });
untouched(f, before);
};
change(next => { next.projects.timer = f.entry; });
change(next => { delete next.projects.stack; });
change(next => { next.projects.stack.root = join(f.root, 'moved'); });
// The record verb writes no document, so it cannot add one either.
assert.equal(typeof f.writer.record, 'function');
assert.equal(Object.hasOwn(f.writer, 'commit'), false, 'the shared commit is private');
});
test('hold_runs_the_check_under_the_writer_lock_after_recovery', t => {
const f = fixture(t), seen = [];
const out = f.writer.hold('acme', ({ revision, document }) => {
seen.push(revision, document.id);
assert.throws(() => f.add(), { code: 'organization-busy' }, 'a writer inside the hold finds the lock');
return 'checked';
});
assert.equal(out, 'checked'); assert.deepEqual(seen, [f.writer.read('acme').revision, 'acme']);
assert.ok(f.add(), 'the lock is released afterwards');
assert.throws(() => f.writer.hold('acme', async () => {}), { code: 'organization-hold-sync' }, 'a hold callback must be synchronous');
});
for (const rejected of [false, true]) {
test(`hold_async_keeps_writers_busy_until_${rejected ? 'rejection' : 'fulfillment'}`, async t => {
const f = fixture(t), before = f.writer.read('acme').revision, failure = new Error('effect failed');
const held = f.writer.holdAsync('acme', async ({ revision }) => {
assert.equal(revision, before);
await Promise.resolve();
assert.throws(() => f.add(), { code: 'organization-busy' });
if (rejected) throw failure;
return 'done';
});
if (rejected) await assert.rejects(held, error => error === failure);
else assert.equal(await held, 'done');
assert.equal(f.writer.read('acme').revision, before);
assert.ok(f.add(), 'the lock releases on either outcome');
});
}
test('a_crash_before_publication_leaves_the_file_and_retry_recovers_the_prepared_receipt', t => {
let armed = true;
const f = fixture(t, { barrier: step => { if (armed && step === 'prepared') { armed = false; throw new Error('killed'); } } });
const before = readFileSync(f.file);
assert.throws(() => f.add(), /killed/);
assert.deepEqual(readFileSync(f.file), before);
assert.equal(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }), null);
f.add();
assert.ok(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
const states = readFileSync(f.log, 'utf8').trim().split('\n').map(l => JSON.parse(l).state);
assert.deepEqual(states, ['prepared', 'aborted', 'prepared', 'committed']);
});
test('a_crash_after_the_rename_still_finds_the_addition_from_the_prepared_receipt', t => {
let armed = true;
const f = fixture(t, { barrier: step => { if (armed && step === 'renamed') { armed = false; throw new Error('killed'); } } });
assert.throws(() => f.add(), /killed/);
assert.deepEqual(f.writer.read('acme').document.projects.timer, f.entry);
const found = f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' });
assert.equal(found.after, f.writer.read('acme').revision);
assert.throws(() => f.add(), { code: 'organization-project-add-refused' });
});
test('managed_state_follows_the_layout_and_never_depends_on_the_marker', t => {
const f = fixture(t), at = { dataRoot: f.dataRoot, business: 'acme', project: 'timer', entry: f.entry };
assert.equal(managedState(at), 'reserved', 'no marker, at the managed layout');
const file = markerFile(at); mkdirSync(join(f.dataRoot, 'products/acme/timer'), { recursive: true });
writeFileSync(file, markerBytes({ state: 'reserved', request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
assert.equal(managedState(at), 'reserved');
writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
assert.equal(managedState(at), 'complete');
writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'other' }));
assert.equal(managedState(at), 'reserved', 'a marker naming another project does not finish this one');
writeFileSync(file, '{ not json');
assert.equal(managedState(at), 'reserved', 'an unreadable marker is not an ordinary project');
rmSync(file);
assert.equal(managedState(at), 'reserved', 'deleting the marker does not make it ordinary');
assert.equal(managedState({ ...at, entry: { ...f.entry, root: join(f.root, 'elsewhere') } }), 'unmanaged');
assert.equal(managedState({ ...at, entry: { ...f.entry, repo: join(f.root, 'elsewhere') } }), 'unmanaged');
assert.equal(managedState({ ...at, entry: mappingEntry() }), 'unmanaged');
assert.equal(managedState({ ...at, entry: null }), 'unmanaged');
});
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter } from '../src/organization.mjs';
import { getProjectRemote, remoteProblem } from '../src/project-remotes.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
doc.projects.books = mappingEntry();
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
return { file, writer: organizationWriter({ dir, rolesDir: rolesCopy(root) }) };
}
const url = 'https://git.example/owner/repo.git';
test('first save changes only the remote and survives a fresh reader', t => {
const { file, writer } = fixture(t), before = writer.read('acme');
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: null, revision: before.revision, firstSaveAllowed: true });
const result = writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
const after = writer.read('acme');
const expected = structuredClone(before.document); expected.projects.stack.remote = url;
assert.deepEqual(after.document, expected);
assert.equal(result.revision, after.revision);
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: url, revision: after.revision, firstSaveAllowed: false });
const log = readFileSync(writer.logFile('acme'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(log.at(-1).changes.map(x => x.path), ['projects.stack.remote']);
assert.equal(JSON.parse(readFileSync(file, 'utf8')).projects.stack.remote, url);
});
test('refusals do not change the business and never echo a supplied credential', t => {
const { file, writer } = fixture(t), before = writer.read('acme'), bytes = readFileSync(file);
const refuse = (args, actor, code) => {
assert.throws(() => writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url, ...args }, actor), e => e.code === code && !e.message.includes('secret'));
assert.deepEqual(readFileSync(file), bytes);
};
for (const value of ['http://git.example/a/b', 'https://user:[email protected]/a/b', 'https://git.example/a/b?secret=x', 'https://git.example/a/b#secret', 'https://git.example/a/../b', 'https://GIT.example/a/b', '', null])
refuse({ remote: value }, 'jason', 'project-remote-invalid');
refuse({ project: 'missing' }, 'jason', 'project-remote-unknown');
refuse({ project: 'books' }, 'jason', 'project-remote-library');
refuse({ revision: 'stale' }, 'jason', 'revision-conflict');
refuse({}, 'tester', 'human-required');
refuse({}, 'coder', 'human-required');
const next = structuredClone(before.document); next.projects.stack.remote = url;
assert.throws(() => writer.write('acme', next, { revision: before.revision, actor: 'jason' }), { code: 'organization-projects-protected' });
next.vars['tracker.pollSeconds']++;
assert.equal(remoteProblem(before.document, next, 'stack', url), 'project-remote-protected');
writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
const saved = readFileSync(file), revision = writer.read('acme').revision;
assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: 'https://git.example/other/repo' }, 'jason'), { code: 'project-remote-configured' });
assert.deepEqual(readFileSync(file), saved);
});
test('the writer lock refuses competing remote writes', t => {
const { writer } = fixture(t), revision = writer.read('acme').revision;
writer.hold('acme', () => assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: url }, 'jason'), { code: 'organization-busy' }));
assert.equal(writer.read('acme').revision, revision);
});
+8 -2
View File
@@ -33,7 +33,9 @@ mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--business <id>] [--json] [--tester]
mosaic project list [--archived] [--business <id>] [--json] [--tester]
mosaic project archive <name-or-id> [--business <id>]
mosaic project unarchive <name-or-id> [--business <id>]
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester]
@@ -99,7 +101,11 @@ mosaic launches list [--json]
the bus record; `project init` and `project check` (`docs/TOOLS.md`, "Local
project records") work on the repository on disk and keep their arguments.
`project list` prints name, repo (`-` for none) and the first 8 characters of
the id. `threads` prints `<project>/<name>`, the instance and the short id of
the id; `--archived` includes archived projects, each marked `archived`.
`project archive <name-or-id>` (#797) is the owner's reversible retirement of
a project: it hides the project, refuses its new threads and messages, deletes
nothing, and `project unarchive <name-or-id>` restores it (resolution reads
the archived list too, so an archived project can be named). `threads` prints `<project>/<name>`, the instance and the short id of
the caller's threads, optionally for one project. `thread create` prints the
id and the instance the broker chose; `thread open` writes the open row the
launcher reads; `thread read` pages the thread's messages oldest first and
+342
View File
@@ -0,0 +1,342 @@
# Request lifecycle acceptance
`request-lifecycle.mjs` is an opt-in submission/observation runner for a later
lead-authorized isolated instance. It uses the existing owner process proof and
the running production host. It does not start a host, launch a scripted role,
select resources or a mode, approve a PRD, mint credentials or provision services.
Prepare a named development instance with its matching `instance.json`, an
existing factory source, installed tracker board and scoped credentials, and
real PM, CTO, chief, coder, reviewer and assistant routes. Start that instance
through the supported entry points in the authorized window. The owner supplies
an existing assistant thread. The runner requires an explicit instance, config,
business, expected owner, request quotation and evidence output directory.
`--owner` checks the configured identity; it does not authenticate anybody.
Example with deliberate placeholders (replace every uppercase value):
```sh
node packages/cli/live/request-lifecycle.mjs \
--instance pomo797 --config /ABS/CONFIG.json --business BUSINESS --owner OWNER \
--thread THREAD_UUID --submit --authorize-isolated-run \
--request-file /ABS/pomodoro-request.json --evidence /ABS/EVIDENCE
```
The owner selects and confirms the exact displayed plan in the signed-in Console.
Autonomous PRD approval remains with the distinct technical arbiter. Guided mode
requires the owner's answers and exact-version approval. No owner relays messages,
creates a task manually, or runs worker shell commands. The quotation in
`../tests/fixtures/pomodoro-request.json` preserves the owner's original wording
and provenance; it is not a raw runtime event export. The new runtime event and
message IDs are retained separately.
The runner retains each observed request revision and final task/review trails.
On complete reviewed delivery it reports the owner-only Console download URL.
Download there, then rerun observation with `--request EVENT_UUID` and
`--artifact /ABS/DOWNLOADED.html` in place of `--submit --thread THREAD_UUID`.
The runner checks the exact reviewed artifact length and SHA-256 and keeps a copy.
Unknown outcomes and timeouts retain evidence and fail; they never trigger a
replacement submission or approval. Output files are created exclusively.
For the lead's live acceptance, retain the original input/message, owner plan
confirmation, active approved snapshot, tracker create/assignment, code commit,
independent reviewer fetch/test record and verdict, closure, delivery receipt,
chief/assistant notification and downloaded bytes. Check that their project,
snapshot, task, commit, review and verdict IDs form the same chain. In an offline
browser, check the approved proposal's work/break durations, start, pause/resume,
reset, phase transition and cue after a user gesture, and no network requests or
external assets. The defaults are agent proposals approved in the product PRD.
Inspect real independent role/test receipts before declaring live acceptance.
The repository fixture runs a separate runtime process over the same persistent
fake tracker and real Store/repositories after each crash. Its scripted models
use shipped role authority, launch admission/limits, session manifests, real typed
tool subprocesses and Git bundles; coder and reviewer run the committed Node
acceptance test independently. It exercises the signed-in HTTP listener and
Chromium. It does not demonstrate live model quality or deployment readiness.
Crash restart follows the existing [operator recovery rule](../../bus/README.md):
there is no automatic stale writer-lock reclaim. The fixture kills and reaps only
its own process group, verifies that exact PID is dead, records the stale lock's
identity and removes only its lock/socket before restart. The database, WAL and
SHM are preserved. A live operator must establish the same facts and preserve
evidence before recovery; this harness does not change that production rule.
## ui797 isolated acceptance support (#797)
These three owner-run tools support a separately provisioned **test** instance.
They do not provision a business, tracker, provider proxy or service units. Keep
all runtime/evidence directories outside this checkout, the config directory and
the instance data root. Use owner-only directories (0700) and campaign files
(0600). No command targets .53 or installs/starts production services implicitly.
The test instance uses its own proxy; there is no shared-proxy attachment here.
Requirements: the repository's pinned Node dependencies, Node with `node:sqlite`,
OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) with
usable user, mount, network and PID namespaces on every browser host. All
examples assume a provisioner already created/validated the named instance through `scripts/bootstrap.sh --instance
ui797`, with a matching `instance.json`. Preserve the owner shell HOME and agent
markers.
For temporary test directories set `TMPDIR` using
`mktemp -d /mnt/storage/scratch/tmp/XXXXXX` (never `/tmp`).
### Local identity and TLS
In an approved setup shell, set `CFG` to the **test** config's absolute path and
`RUNROOT` to an existing owner-only runtime/evidence parent. The config's parent
must be named `mosaic-ui797`. The following creates a new identity directory,
private two-day CA/server certificates, a private RS256 signing key and the
0600 slot `<configDir>/secrets/mosaic-stack/ui797-local`. Existing files refuse.
It prints the JSON block to insert into the test config, without changing that
config or printing the client secret:
```sh
node packages/cli/live/isolated-identity.mjs prepare \
--instance ui797 --config "$CFG" --owner ui797-owner \
--runtime "$RUNROOT/identity" --tls-port 17443
```
Add that exact `console.sso` block through the approved test-config setup step.
The public origin is `https://127.0.0.1:17443`, issuer
`https://127.0.0.1:17443/oidc/`, behind the TLS forwarder to Console port 17440.
There is no identity listener on port 17442. Only `ui797-agent-owner` can be
issued, displayed as `ui797-owner`. Authorization immediately redirects back with a random,
single-use 60-second code, state and S256 PKCE; signed ID tokens last 300 seconds.
There is no refresh grant, alternate subject or live identity-provider integration.
Run these as separate normally supervised **test** services, not Console children
of a browser agent. Preserve the real production entry point and ancestry proof:
```sh
node packages/cli/live/isolated-identity.mjs serve --runtime "$RUNROOT/identity"
```
```sh
MOSAIC_CONFIG="$CFG" NODE_EXTRA_CA_CERTS="$RUNROOT/identity/ca.pem" \
node packages/webui/src/cli.mjs serve --business mvp3-ui797 \
--port 17440 --board http://127.0.0.1:17439 --sso --send
```
The identity service binds only 127.0.0.1:17443 and forwards non-OIDC paths to
127.0.0.1:17440 with the original Host/Origin/cookies. It adds no subject headers.
The Console validates discovery, JWKS, PKCE, nonce, token signature and its normal
session/owner rules. A missing bus, business or ancestry proof still refuses.
No human/Console binding shortcut or TLS-verification disable flag is used.
Trust `ca.pem` via `NODE_EXTRA_CA_CERTS` only in these test services, never globally.
Prepare and serve require the literal `ui797`, a `mosaic-ui797` config directory
and the current matching data-root marker. Serve revalidates persisted metadata;
the budget commands enforce the same binding.
Certificates expire after two days; do not overwrite identity material on restart.
### Step-by-step real browser CLI for tester agents
`start` creates an exclusive evidence directory and fresh profile, then starts a
private Unix-socket daemon. Subsequent invocations reuse that daemon. Either set
`UI797_EVIDENCE` or pass `--evidence DIR` on each command. Only the exact loopback
origins `http://127.0.0.1:17440` and `https://127.0.0.1:17443` are accepted. Every
page/subresource redirect must stay on the selected origin. Other targets and
WebSocket connections outside that origin are blocked before the handshake by a
forced local HTTP proxy with Chromium's loopback bypass disabled. It opens sockets
only to the selected origin, refuses HTTP upgrades, and permits CONNECT only to
the selected HTTPS host/port. Chromium and its TCP relay run in a mandatory Bubblewrap
network namespace, with its own loopback up and no external interfaces or routes.
The relay accepts only the forced proxy's TCP port and carries bytes over a 0600
Unix socket to that origin-checking host proxy. All other TCP and UDP, including
IPv4/IPv6 STUN/TURN on other loopback addresses, stay inside the empty namespace.
Bubblewrap's PID namespace and parent-death handling also contain its children.
The WebRTC flag alone does not prevent UDP on Chromium 156; it remains defense
in depth, alongside disabling QUIC. Request interception also guards
redirects and subresources. A detected
blocked network attempt makes the session fail until
`stop`. The driver never contacts 7332 or mosaic.woltje.com.
The browser trusts the test CA through a private NSS database under its evidence
directory. Bubblewrap starts with an empty mount view, adds read-only `/usr`,
libraries and selected NSS/font/certificate configuration, a minimal private
`/dev` and `/proc`, and private tmpfs `/tmp`, `/run` and home. Only this browser's
evidence/profile/download directories and proxy/control sockets are writable
host mounts. Host home, runtime directories, pathname sockets and credential
paths are hidden. An explicit environment allowlist supplies a private HOME and
XDG directories; host D-Bus/SSH-agent addresses and secrets are not inherited.
The private NSS database is mounted at the sandbox HOME's `.pki/nssdb`; the
owner shell HOME and host trust files remain unchanged. Certificate
verification stays enabled; a different CA is rejected. Startup refuses when the
required trust setup or namespace is unavailable; it does not fall back to a
certificate-error exception.
Exact commands for a tester (replace discovered refs and button names with the
current `snapshot` output):
```sh
export UI797_EVIDENCE="$RUNROOT/browser-a"
node packages/cli/live/ui-browser.mjs start \
--evidence "$UI797_EVIDENCE" --origin https://127.0.0.1:17443 \
--ca "$RUNROOT/identity/ca.pem"
node packages/cli/live/ui-browser.mjs goto '/#chat'
node packages/cli/live/ui-browser.mjs snapshot
node packages/cli/live/ui-browser.mjs click --text Chat
node packages/cli/live/ui-browser.mjs type r1-123 'Build a Pomodoro timer.'
node packages/cli/live/ui-browser.mjs press Enter
node packages/cli/live/ui-browser.mjs wait --text 'Choose how to proceed' --timeout 120
node packages/cli/live/ui-browser.mjs screenshot request-plan
node packages/cli/live/ui-browser.mjs click r1-456
node packages/cli/live/ui-browser.mjs downloads
node packages/cli/live/ui-browser.mjs stop
```
With a sole provider, the initial page load signs in immediately via the normal
OIDC redirects. `snapshot` reports visible accessibility-tree text and interactive
roles/names/disabled states, with document-scoped stable refs. Snapshot again after
navigation; stale refs refuse. `click --text` needs exactly one enabled exact
accessible-name match. `type` focuses with a real mouse click and inserts text at
the current caret via CDP Input; it appends rather than clears. Supported keys:
Enter, Tab, Escape, Backspace, Delete, Home/End, PageUp/Down, arrows, Space and
letters/digits, with Ctrl/Control, Alt, Meta or Shift modifiers (for example
`press Ctrl+A`, `press Backspace` to clear a focused field). Clicks use
real CDP mouse input. No page-function calls or fetch/state mocks are injected.
`wait` is bounded to 120 seconds per invocation. No uncertain write is replayed.
A click that opens a native alert/confirm/prompt returns `{dialog:{type,message}}`;
answer it with `dialog accept` or `dialog dismiss` (for example the Console's
Resume confirmation). While a dialog is open every other command except `stop`
refuses, and no screenshot is taken because the page is blocked; the receipt
records the dialog type, message and answer.
Each accepted action, including failed commands, start and stop, writes
`NNNNN-before.png`, `NNNNN-after.png` and one `actions.jsonl` receipt with result
status. The journal omits URLs, query strings, text inputs, headers, bodies and
cookies. Screenshots show page contents, so keep all evidence private and avoid
entering secrets. Snapshots omit input values. Downloads are saved under browser
GUIDs and listed with absolute paths, byte lengths and SHA-256; compare these
with the product's delivery receipt. Partial downloads refuse. Profiles/evidence
are retained on stop. An interrupted daemon is not automatically replaced or its
commands replayed: inspect receipts and use a new evidence directory for a new
explicitly authorized session. `stop` remains available after a refused network
request. These tools do not yet open downloaded artifacts in an offline target.
### Durable metered campaign stop
An owner creates a single 0600 `campaign.json` **before launching inference**:
```json
{
"version": 1,
"instance": "ui797",
"config": "/ABS/CONFIG/mosaic-ui797/config.json",
"business": "mvp3-ui797",
"owner": "ui797-owner",
"start": "2026-11-06T02:00:00.000Z",
"end": "2026-11-06T03:00:00.000Z",
"caps": [
{"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":100000000},
{"provider":"proxy","route":"anthropic","model":"claude-opus-5-5","tokens":10000000},
{"provider":"proxy","route":"anthropic","model":"claude-sonnet-5-5","tokens":10000000}
]
}
```
Use the approved actual absolute config path, canonical UTC campaign start/end
and **all** actual model routes; the dates above illustrate the schema. Caps are
positive safe integers: at most 100,000,000 for exactly
`proxy/glm/glm-5.3-flash`, and 10,000,000 for every other approved model. Lower
caps allow zero-spend tests. Only proxy GLM, Claude (`anthropic` route, `claude-*`)
and Codex (`codex` route, `gpt-*`) bindings are accepted. One provider/model has
one allowance across all routes; conflicting bindings refuse. The campaign must
end by **2026-11-07T06:00:00.000Z**; earlier windows narrow that absolute cutoff,
which checks also enforce independently.
The test bus store must exist before initialization. Run the supervisor outside
model-writable settings/data in a normally supervised owner service:
```sh
node packages/cli/live/budget-supervisor.mjs init \
--campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger"
node packages/cli/live/budget-supervisor.mjs serve \
--campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger" --interval 1
```
On-demand commands (safe for a tester to request; a triggered halt still requires
the normal owner-service ancestry):
```sh
node packages/cli/live/budget-supervisor.mjs check \
--campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger"
node packages/cli/live/budget-supervisor.mjs status \
--campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger"
```
`status` prints persisted totals from the last successful accounting check versus
each model's `tokens` cap, the end time and latch state. It does not refresh usage.
The supervisor opens only the pinned test bus SQLite store read-only, using the
same `turnRecords` accounting as token-limit (input + output + cacheWrite +
cacheRead). Proxy telemetry is excluded. A run's immutable bundle manifest supplies
its actual `proxyRoute`; fallback/drift/uncapped models refuse. Delivered/read
messages and ended runs detect missing completed usage. An in-flight begin waits
for its outcome; unknown/malformed completed usage stops the campaign.
Each provider/route/model has a separate append-only ledger, durable across
rounds/restarts. Campaign bytes, config bytes, root identity and store identity are pinned;
changed caps/epoch, rewritten/disappeared usage and damaged ledger files stop.
Checks recompute each journal entry's tokens and totals from authoritative usage;
a valid-JSON token mismatch durably latches and halts.
Initialization exclusively writes `campaign.json.binding.json` beside the owner
campaign, outside the ledger volume, before creating the ledger. Preserve this
0600 file and the campaign: they independently pin the target and ledger path
when accounting storage is unavailable. Checks never recreate that file or an
accounting epoch. The version-2 ledger manifest independently pins config bytes,
so missing, unreadable, malformed or wrong-mode sidecars stop and still attempt halt against
the verified target. Each report and halt receipt includes `evidenceErrors`;
damaged files are preserved. An intact sidecar must agree with both the owner
campaign bytes and ledger campaign/epoch. A running service retains its verified
binding and epoch while revalidating the actual target on every tick. Legacy
version-1 ledgers require their intact independent sidecar to verify config bytes;
a sidecar-less legacy ledger refuses targeting. Never reinitialize or reset an
existing campaign to work around missing evidence.
Initialization is exclusive. There is no reset, raise or resume command. A stale
`check.lock` after interruption requires owner diagnosis; never auto-reclaim it.
An existing or uncreatable lock prevents accounting and still asserts the sticky
halt against the verified ui797 binding on every check/serve tick. The lock is
preserved, including when a stop was already latched.
A changed target config refuses rather than halting an unverified data root.
On `campaign-instance-changed`, the owner must diagnose the campaign's pin,
binding (including root and store identity), config bytes and ledger target against
the original evidence. Restore the original files and target identities if they
can be verified, or end the campaign; there is no supported retarget or recovery
command. Never reinitialize, reset or raise the existing campaign, or auto-reclaim
its lock. A refused target is not proof of a halt or quiescence.
At any cap, deadline or accounting refusal, the supervisor fsyncs `stop.json`
before invoking the existing `mosaic halt --business ... --json` production CLI
with the selected test `MOSAIC_CONFIG`. The CLI's human-shell/ancestry proof stays
intact. A refusal/unknown stop is retained as a failed `halt-N.json` receipt and
`check` exits nonzero; the latch remains. Missing, renamed or nonprivate ledger
directories still trigger halt using the independently validated owner binding.
If storage cannot persist a latch or receipt, it explicitly reports
`stop-latch-not-durable` and/or `stop-receipt-not-durable` after attempting halt;
durable evidence then needs
owner repair before any further campaign work. Do not strip markers, direct-bind a principal,
write processing state, or treat that receipt as quiescence. A properly installed
owner service must perform/confirm halt. `serve` stays running on check/halt/storage
errors, reports each failed tick and reasserts halt on every tick, including after
a modelled Resume. It retains an in-memory stop if storage subsequently recovers.
Every attempt revalidates the target; invalid, changed or foreign bindings refuse
targeting. It never resumes. Manual Resume is outside the campaign protocol.
This is a **metered stop**, not strict inference admission or a hard provider
ceiling. Concurrent/in-flight work, transcript accounting and the 0.1–10-second
check interval can overshoot a cap. Install the native token-limit controls as
well, retain accounting evidence, and use only the lead-approved metered-stop
acceptance envelope. These tools add no model-dispatch hook or network quota gate.
Focused verification (no model spend, no remote access):
```sh
TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX) \
node --test --test-concurrency=1 packages/cli/live/*.test.mjs
```
Run these files sequentially: their integration fixtures exclusively bind the
fixed test ports 17440/17443 and fail if another process owns them. The browser
SSO test starts the unchanged production `cli.mjs serve --sso` without `--send`;
it proves genuine sign-in, not authenticated broker writes or real-model quality.
The budget tests inject a tiny cap and a fake halt at the supervisor boundary;
they also prove the real CLI refuses an agent environment. No live business is
halted by these tests.
+36
View File
@@ -0,0 +1,36 @@
// Runs only inside Bubblewrap's network/PID namespaces. No host TCP/UDP handle
// is inherited: the single TCP listener relays bytes to the fixed Unix proxy.
import { spawn } from 'node:child_process';
import { createServer, createConnection } from 'node:net';
import { closeSync, readlinkSync } from 'node:fs';
import { networkInterfaces } from 'node:os';
const [hostNamespace, socket, port, executable, ...args] = process.argv.slice(2);
if (readlinkSync('/proc/self/ns/net') === hostNamespace ||
Object.keys(networkInterfaces()).some(name => name !== 'lo')) {
throw new Error('isolated browser network required');
}
const sockets = new Set();
const track = s => { sockets.add(s); s.once('close', () => sockets.delete(s)); return s; };
const server = createServer(peer => {
track(peer);
const upstream = track(createConnection(socket));
peer.on('error', () => upstream.destroy()); upstream.on('error', () => peer.destroy());
peer.once('close', () => upstream.destroy()); upstream.once('close', () => peer.destroy());
peer.pipe(upstream); upstream.pipe(peer);
});
await new Promise((resolve, reject) => {
server.once('error', reject); server.listen(Number(port), '127.0.0.1', resolve);
});
const child = spawn(executable, args, { stdio:['ignore','ignore','ignore',3,4] });
// Chromium alone owns the CDP pipes. Do not hold its EOF open on startup failure.
closeSync(3); closeSync(4);
let closing;
const close = () => closing ??= (async () => {
const closed = new Promise(resolve => server.close(resolve));
for (const s of sockets) s.destroy();
await closed;
})();
child.once('error', () => close().then(() => { process.exitCode = 1; }));
child.once('exit', (code, signal) => close().then(() => { process.exitCode = signal ? 1 : code; }));
for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => child.kill(signal));
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env node
// Metered stop, with accounting/in-flight delay. Never a provider-side hard ceiling.
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { turnRecords } from '../../bus/src/usage.mjs';
import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, ROOT, testInstance } from './support-common.mjs';
export const CUTOFF = Date.parse('2026-11-07T06:00:00.000Z');
const modelIdentity = c => JSON.stringify([c.provider,c.model]);
// The charter permits the GLM identity and Claude/Codex coder model families via proxy.
const approvedBinding = c => c.provider === 'proxy' && (
(c.route === 'glm' && c.model === 'glm-5.3-flash') ||
(c.route === 'anthropic' && /^claude-[a-zA-Z0-9._-]+$/.test(c.model)) ||
(c.route === 'codex' && /^gpt-[a-zA-Z0-9._-]+$/.test(c.model)));
const ceiling = c => c.provider === 'proxy' && c.route === 'glm' && c.model === 'glm-5.3-flash' ? 100000000 : 10000000;
const kinds = ['input','output','cacheWrite','cacheRead'];
const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(x)).digest('hex');
const identity = c => JSON.stringify([c.provider,c.route,c.model]);
const filename = c => digest(identity(c)) + '.jsonl';
const canonicalTime = s => typeof s === 'string' && Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s;
function campaignAt(path) {
privateDir(dirname(path));
const text = readPrivate(path), c = JSON.parse(text);
validateCampaign(c);
return { c, hash:digest(text) };
}
function validateCampaign(c) {
if (c.instance !== 'ui797' || c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || Date.parse(c.end) > CUTOFF ||
!Array.isArray(c.caps) || !c.caps.length || c.caps.length > 20) fail('campaign-invalid');
const keys = new Set();
for (const cap of c.caps) {
if (Object.keys(cap).sort().join(',') !== 'model,provider,route,tokens' || !['proxy','anthropic','zai','openai'].includes(cap.provider) ||
!['glm','anthropic','codex'].includes(cap.route) || !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$/.test(cap.model ?? '') ||
!approvedBinding(cap) || !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > ceiling(cap) || keys.has(modelIdentity(cap))) fail('campaign-cap-invalid');
keys.add(modelIdentity(cap));
}
}
function binding(c) {
const { system } = testInstance(c.instance,c.config);
const st = lstatSync(system.dataRoot), dbPath = join(system.dataRoot,'bus/bus.sqlite'); plainPath(dbPath);
const db = lstatSync(dbPath);
if (!db.isFile() || db.uid !== process.getuid()) fail('test-store-required');
return { instance:c.instance,config:c.config,dataRoot:system.dataRoot,business:c.business,owner:c.owner,rootIdentity:[st.dev,st.ino],storeIdentity:[db.dev,db.ino] };
}
function checkBinding(pinned, c) {
const b = binding(c);
if (JSON.stringify(b) !== JSON.stringify(pinned)) fail('campaign-instance-changed');
return b;
}
function writeJournal(file, row) {
readPrivate(file);
const fd = openSync(file,'a');
try { writeFileSync(fd,JSON.stringify(row)+'\n'); fsyncSync(fd); } finally { closeSync(fd); }
}
function flushDir(dir) { const fd = openSync(dir,'r'); try { fsyncSync(fd); } finally { closeSync(fd); } }
export function initialize({ campaign, ledger }) {
const { c,hash } = campaignAt(campaign), b = binding(c);
outsideRuntime(campaign,{dataRoot:b.dataRoot},c.config);
outsideRuntime(ledger,{dataRoot:b.dataRoot},c.config);
// Independent owner evidence survives loss of the accounting volume. Created
// only at initialization; a check must never reconstruct an epoch or this pin.
const configHash=digest(readFileSync(c.config,'utf8'));
exclusive(campaign+'.binding.json',JSON.stringify({version:1,hash,configHash,binding:b,ledger,campaign:c})+'\n');
flushDir(dirname(campaign));
privateDir(ledger,true);
for (const cap of c.caps) exclusive(join(ledger,filename(cap)),JSON.stringify({ version:1,cap })+'\n');
exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:2,hash,configHash,binding:b,campaign:c })+'\n');
flushDir(ledger);
return { initialized:true,ledger };
}
export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
const completed = store.all(`SELECT DISTINCT d.holder_run run,m.id turn,d.at FROM deliveries d JOIN messages m ON m.id=d.message
WHERE m.business=? AND d.op='read' AND d.holder_run IS NOT NULL AND d.at>=?`,business,start);
const ended = store.all("SELECT subject run,at FROM events WHERE business=? AND kind='session.ended' AND at>=?",business,start);
db.exec('COMMIT');
const phases = new Map(raw.map(r => [JSON.stringify([r.run,r.turn,r.source]),r.phase]));
const endedRuns = new Set(ended.map(r=>r.run));
for (const r of raw) if (!['begin','end'].includes(r.phase) || !['known','unknown'].includes(r.status)) fail('usage-malformed');
const interrupted = store.all(`SELECT DISTINCT d.holder_run run,m.id turn FROM deliveries d JOIN messages m ON m.id=d.message JOIN events e ON e.subject=d.holder_run AND e.business=m.business AND e.kind='session.ended' WHERE m.business=? AND d.op='delivered' AND e.at>=?`,business,start);
for (const t of interrupted) if (!records.some(r=>r.run===t.run && r.turn===t.turn && r.status==='known')) fail('usage-missing');
for (const r of records) {
if (!canonicalTime(r.at)) fail('usage-malformed');
if (r.at < start) continue;
// An in-flight begin is not unknown COMPLETED usage. It is checked on completion/end.
if (phases.get(JSON.stringify([r.run,r.turn,r.source])) !== 'end' && !endedRuns.has(r.run) && !completed.some(x=>x.run===r.run && x.turn===r.turn)) continue;
if (r.status !== 'known') fail('usage-unknown');
}
const selected = records.filter(r => r.at >= start && r.status === 'known');
for (const t of completed) if (!selected.some(r=>r.run===t.run && r.turn===t.turn)) fail('usage-missing');
// Ended runs with begun turns must have a completed record for every source.
const rows = selected.map(r => {
if (!/^r[0-9a-f]{12}$/.test(r.run)) fail('usage-run-invalid');
const manifest = JSON.parse(readPrivate(join(dataRoot,'launches',business,r.run,'bundle/manifest.json')));
if (manifest.business !== business || manifest.run !== r.run || manifest.instance !== r.instance || manifest.provider !== r.provider || manifest.model !== r.model || manifest.route !== 'primary') fail('usage-route-drift');
if (!['glm','anthropic','codex'].includes(manifest.proxyRoute)) fail('usage-route-unknown');
return { ...r,route:manifest.proxyRoute };
});
return rows;
} finally { db.close(); }
}
export function supportedHalt(b, env=process.env) {
// This supervisor must be installed as a genuine owner service. Never strip agent markers,
// bindHuman/bindConsole, forge ancestry, or write control/processing.json directly.
const p = spawnSync(process.execPath,[join(ROOT,'packages/cli/src/cli.mjs'),'halt','--business',b.business,'--json'],{
env:{...env,MOSAIC_CONFIG:b.config},encoding:'utf8',timeout:40000,maxBuffer:1024*1024,
});
if (p.status !== 0) fail('supported-halt-refused-or-unknown');
let result; try { result=JSON.parse(p.stdout); } catch { fail('supported-halt-invalid-reply'); }
if (result.halted !== true || result.quiescent !== true) fail('supported-halt-not-quiescent');
return { halted:true,quiescent:true,generation:result.generation };
}
function journal(ledger,cap) {
const text = readPrivate(join(ledger,filename(cap)));
if (!text.endsWith('\n')) fail('ledger-truncated');
const [header,...entries] = text.trimEnd().split('\n').map(JSON.parse);
if (header.version !== 1 || JSON.stringify(header.cap) !== JSON.stringify(cap)) fail('ledger-header-changed');
const seen = new Map(); let total = 0;
for (const e of entries) {
if (typeof e.key !== 'string' || typeof e.digest !== 'string' || !Number.isSafeInteger(e.tokens) || e.tokens < 0 || seen.has(e.key)) fail('ledger-malformed');
seen.set(e.key,e); total += e.tokens;
if (!Number.isSafeInteger(total)) fail('ledger-overflow');
}
return { entries:seen,total };
}
function loadLedger(ledger) {
privateDir(ledger); const p=JSON.parse(readPrivate(join(ledger,'campaign.json')));
if (![1,2].includes(p.version) || !p.binding || !p.campaign ||
(p.version===2 && !/^[0-9a-f]{64}$/.test(p.configHash))) fail('ledger-manifest-invalid');
return p;
}
// A ledger's target may direct a halt only when independent evidence names the
// same target: the owner campaign itself, the owner's pin, or the instance the
// current owner campaign resolves to. A conflicting ledger never authorizes its
// own target. The owner-verified pin replaces it only when the pin agrees with
// the owner campaign and the live config/marker/root/store; anything else refuses.
// A pin corroborates only a target its own campaign names and, while the owner
// campaign is readable, the target the owner names too: a pin whose binding was
// damaged to match a damaged ledger justifies nothing (support r5 P1). The pin and
// ledger are both writable by whoever damaged them; only the owner campaign is not.
// So with no retained startup evidence, an owner business/config edit refuses as an
// ambiguous target. A running service still halts its retained startup target while
// the ledger is exactly the one it verified at startup.
const TARGET_FIELDS=['instance','config','business','owner'];
const names=(b,c)=>!!c && TARGET_FIELDS.every(k=>b?.[k]===c[k]);
function corroborate(p,pin,{campaign,ledger},retained=null) {
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
let owner=null, live=null, liveHash=null;
try { owner=campaignAt(campaign); live=binding(owner.c); liveHash=digest(readFileSync(owner.c.config,'utf8')); } catch { /* No owner evidence. */ }
if(owner && owner.hash===p.hash && same(owner.c,p.campaign)) return p;
if(retained && retained.ledger===ledger && retained.p.hash===p.hash && same(retained.p.campaign,p.campaign) &&
same(retained.p.binding,p.binding) && retained.configHash===p.configHash) return p;
const pinCampaign=pin && (pin.campaign ?? (owner && pin.hash===owner.hash ? owner.c : null));
if(pin && pin.ledger===ledger && same(pin.binding,p.binding) && pin.configHash===p.configHash &&
names(pin.binding,pinCampaign) && (!owner || names(pin.binding,owner.c))) return p;
if(live && same(live,p.binding)) return p;
if(owner && pin && pin.ledger===ledger && pin.hash===owner.hash && (!pin.campaign || same(pin.campaign,owner.c)) &&
same(pin.binding,live) && pin.configHash===liveHash)
return {campaign:owner.c,hash:pin.hash,configHash:pin.configHash,binding:pin.binding};
fail('campaign-instance-changed');
}
function checkTarget({campaign,ledger}, retained=null) {
let p, pin, reason=null, writable=true;
const evidenceErrors=[];
try { privateDir(ledger); } catch { writable=false; reason='ledger-storage-unavailable'; }
try {
pin=JSON.parse(readPrivate(campaign+'.binding.json'));
if(pin?.version!==1 || !pin.binding || !/^[0-9a-f]{64}$/.test(pin.configHash)) throw new Error('invalid');
} catch(e) {
pin=null;
evidenceErrors.push(e.code==='ENOENT'?'owner-binding-missing':'owner-binding-unreadable-or-invalid');
}
try { if (!writable) throw new Error(reason); p=loadLedger(ledger); } catch {
reason??='ledger-manifest-invalid';
if(retained) p=retained.p;
else {
if(!pin || pin.ledger!==ledger) fail('owner-binding-unverified');
const {c,hash}=campaignAt(campaign);
// A fallback must never infer a new target/epoch from altered owner bytes.
if(pin.hash!==hash) fail('owner-binding-invalid-or-changed');
p={campaign:pin.campaign??c,hash:pin.hash,configHash:pin.configHash,binding:pin.binding};
}
}
if(p.configHash) {
const resolved=corroborate(p,pin,{campaign,ledger},retained);
if(resolved!==p) { p=resolved; evidenceErrors.push('ledger-target-changed'); }
}
const c=p.campaign;
// Pin the target before any halt. A changed config cannot redirect the stop into live data.
checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config);
outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config);
const configHash=p.configHash??pin?.configHash??retained?.configHash;
if(!configHash) fail('owner-binding-unverified');
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
for(const source of [pin,retained && {...retained.p,ledger:retained.ledger,configHash:retained.configHash}].filter(Boolean)) {
if(source.ledger!==ledger || JSON.stringify(source.binding)!==JSON.stringify(p.binding) || source.configHash!==configHash) {
if(source!==pin || !p.configHash) fail('campaign-instance-changed');
evidenceErrors.push('owner-binding-target-changed');
}
if(source.hash!==p.hash || (source.campaign && JSON.stringify(source.campaign)!==JSON.stringify(c)))
evidenceErrors.push('owner-binding-epoch-changed');
}
reason??=evidenceErrors[0]??null;
return {p,c,reason,writable,configHash,evidenceErrors};
}
export async function check(opts, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync, stopReason=null, retained=null }={}) {
const {ledger,campaign}=opts;
const {p,c,reason:targetReason,writable,configHash,evidenceErrors}=checkTarget(opts,retained);
const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false;
if(writable) try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; }
// An interrupted/active/unwritable lock prevents accounting, never the verified halt.
// Only the check that created a lock may remove it on normal completion.
try {
let reason=targetReason ?? stopReason ?? (lockFailure?'check-lock-unavailable':null), totals=[];
const stopFile=join(ledger,'stop.json');
if (writable && existsSync(stopFile)) {
reason='stop-already-latched';
try { JSON.parse(readPrivate(stopFile)); } catch { reason='stop-latch-invalid'; }
}
try {
const clock=now(); if (!Number.isFinite(clock)) fail('campaign-clock-invalid');
if (clock>=CUTOFF) fail('campaign-absolute-cutoff');
validateCampaign(c);
const owner=campaignAt(campaign);
if (owner.hash !== p.hash || JSON.stringify(owner.c) !== JSON.stringify(c)) fail('campaign-changed');
if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended');
if (!reason) {
const records=await read({...p.binding,start:c.start,end:c.end}), observed=new Set();
const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap),total:0}));
for (const r of records) {
if (!canonicalTime(r.at) || r.at<c.start || r.status!=='known' || kinds.some(k=>!Number.isSafeInteger(r[k]) || r[k]<0)) fail('usage-unknown-or-malformed');
const a=accounts.find(a=>identity(a.cap)===identity(r)); if (!a) fail('usage-uncapped-route-model');
const key=JSON.stringify([r.run,r.turn,r.source]); if (observed.has(key)) fail('usage-duplicate'); observed.add(key);
const tokens=kinds.reduce((sum,k)=>sum+r[k],0); if (!Number.isSafeInteger(tokens)) fail('usage-overflow');
const hash=digest(r), previous=a.entries.get(key);
if (previous && previous.digest !== hash) fail('usage-rewritten');
if (previous && previous.tokens !== tokens) fail('ledger-token-mismatch');
a.total+=tokens; if (!Number.isSafeInteger(a.total)) fail('ledger-overflow');
if (!previous) {
const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e);
}
}
for (const a of accounts) for (const key of a.entries.keys()) if (!observed.has(key)) fail('usage-disappeared');
totals=accounts.map(a=>({...a.cap,total:a.total}));
// Route journals are evidence, never separate allowances for the same model.
const models=new Map();
for (const a of totals) {
const key=modelIdentity(a), model=models.get(key) ?? {total:0,tokens:Math.min(a.tokens,ceiling(a))};
model.total+=a.total; model.tokens=Math.min(model.tokens,a.tokens,ceiling(a));
if (!Number.isSafeInteger(model.total)) fail('ledger-overflow');
models.set(key,model);
}
if ([...models.values()].some(a=>a.total>=a.tokens)) reason='model-cap-reached';
}
} catch(e) { reason=/^[a-z-]{1,80}$/.test(e.message)?e.message:'accounting-failed'; }
if (reason) {
const storageErrors=[];
try {
if(!writable) throw new Error('unavailable');
if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals,evidenceErrors})+'\n'); flushDir(ledger); }
} catch { storageErrors.push('stop-latch-not-durable'); }
// Always attempt halt, even when the evidence volume cannot persist the latch.
let result,error;
try {
checkBinding(p.binding,c);
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
result=await halt(p.binding);
} catch { error='supported-halt-refused-or-unknown'; }
try {
if(!writable) throw new Error('unavailable');
const attempt=readdirSync(ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length+1;
exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError:storageErrors[0]??null,evidenceErrors})+'\n'); flushDir(ledger);
} catch { storageErrors.push('stop-receipt-not-durable'); }
if (storageErrors.length) throw Object.assign(new Error(storageErrors.join('; ')),{
result:{stopped:true,reason,totals,halt:result??null,haltError:error??null,storageErrors,evidenceErrors},
});
if (error) throw Object.assign(new Error(error),{result:{stopped:true,reason,totals,evidenceErrors}});
return {stopped:true,reason,totals,halt:result,evidenceErrors};
}
return {stopped:false,totals};
} finally { if (locked) try { rmdirSync(lock); } catch { /* Preserve an unavailable lock; never recreate its parent. */ } }
}
export function status({ campaign,ledger }) {
const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign);
const target=checkTarget({campaign,ledger});
if(target.reason) fail(target.reason);
outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config);
const owner=campaignAt(campaign);
if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed');
const totals=p.campaign.caps.map(cap=>({...cap,total:journal(ledger,cap).total}));
return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals };
}
export async function serveBudget(opts, {interval=1000,signal,report=value=>console.log(JSON.stringify(value)),...deps}={}) {
// Retain independent target/config/epoch evidence, but revalidate the actual
// config, marker, root and store on EVERY tick, including just before halt.
// Every tick uses the startup campaign and ledger paths, and the retained clone
// records that ledger, so a caller that later mutates its options object cannot
// move stop/halt receipts to another ledger (support r5).
const fixed=Object.freeze({campaign:opts.campaign,ledger:opts.ledger});
const target=checkTarget(fixed),retained=structuredClone({p:target.p,configHash:target.configHash,ledger:fixed.ledger});
let stopReason=null;
while(!signal?.aborted) {
try {
const result=await check(fixed,{...deps,stopReason,retained});
if(result.stopped) stopReason=result.reason;
report(result);
} catch(e) {
// Keep the owner service alive even if evidence storage or halt fails. A
// changed/foreign target is revalidated and refused on every later tick.
stopReason??=e.result?.reason ?? 'supervisor-check-failed';
report({...e.result,error:e.message});
}
if(!signal?.aborted) await new Promise(resolve=>{
const done=()=>{clearTimeout(timer);signal?.removeEventListener('abort',done);resolve();};
const timer=setTimeout(done,interval);signal?.addEventListener('abort',done,{once:true});
});
}
}
export async function main(argv) {
const command=argv.shift(),o=options(argv,['--campaign','--ledger','--interval']);
const opts={campaign:o['--campaign'],ledger:o['--ledger']};
if (command==='init') return initialize(opts);
if (command==='status') return status(opts);
if (command==='check') return check(opts);
if (command!=='serve') fail('use init, check, serve or status');
const interval=Number(o['--interval']??1); if (!Number.isFinite(interval) || interval<0.1 || interval>10) fail('interval must be 0.1..10 seconds');
const controller=new AbortController();
const stop=()=>controller.abort();
for(const signal of ['SIGTERM','SIGINT']) process.once(signal,stop);
try { await serveBudget(opts,{interval:interval*1000,signal:controller.signal}); }
finally { for(const signal of ['SIGTERM','SIGINT']) process.removeListener(signal,stop); }
}
if (isMain(import.meta.url)) main(process.argv.slice(2)).then(r=>{if(r!==undefined)console.log(JSON.stringify(r,null,2));}).catch(e=>{console.error(e.message);process.exitCode=1;});
@@ -0,0 +1,584 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync,chmodSync,rmSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { join } from 'node:path';
import { initialize,check,status,readAccounting,supportedHalt,main,serveBudget,CUTOFF } from './budget-supervisor.mjs';
import { fixture,closeChild } from './test-support.mjs';
import { Store,SCHEMA_VERSION } from '../../bus/src/store.mjs';
const NOW=Date.parse('2026-11-06T02:30:00.000Z');
const row=(extra={})=>({at:'2026-11-06T02:29:00.000Z',run:'r123456789abc',turn:'t1',source:'transcript',instance:'assistant',provider:'proxy',route:'glm',model:'glm-5.3-flash',status:'known',input:1,output:1,cacheWrite:1,cacheRead:1,...extra});
function setup(t,tokens=5,changes={}) {
const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close());
const campaign=join(f.root,'campaign.json'),ledger=join(f.root,'ledger');
const c={version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens},{provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:6}]};
Object.assign(c,changes);
writeFileSync(campaign,JSON.stringify(c),{mode:0o600});initialize({campaign,ledger});
const halts=[],halt=b=>{halts.push(b);return {halted:true,quiescent:true,generation:'test'};};
return {...f,store,campaign,ledger,c,halts,halt,opts:{campaign,ledger}};
}
test('tiny cap counts all four token kinds per model, persists/deduplicates across checks and latches entire campaign',async t=>{
const f=setup(t);const opts={now:()=>NOW,halt:f.halt,read:()=>[row()]};
assert.equal((await check(f.opts,opts)).stopped,false);
assert.equal((await check(f.opts,opts)).totals[0].total,4);assert.equal(f.halts.length,0);
const stopped=await check(f.opts,{...opts,read:()=>[row(),row({turn:'t2',input:0,output:1,cacheRead:0,cacheWrite:0})]});
assert.equal(stopped.reason,'model-cap-reached');assert.equal(stopped.totals[0].total,5);assert.equal(stopped.totals[1].total,0);
assert.equal(f.halts[0].dataRoot,f.dataRoot);
assert.equal(status(f.opts).stopped,true);assert.equal(status(f.opts).totals[0].total,5);
assert.equal((await check(f.opts,opts)).reason,'stop-already-latched');assert.equal(f.halts.length,2);
assert.throws(()=>initialize(f.opts));
});
for(const [name,records,reason] of [
['unknown',[row({status:'unknown'})],'usage-unknown-or-malformed'],
['malformed',[row({input:-1})],'usage-unknown-or-malformed'],
['unsafe integer',[row({input:Number.MAX_SAFE_INTEGER+1})],'usage-unknown-or-malformed'],
['route drift',[row({route:'anthropic'})],'usage-uncapped-route-model'],
['unlisted model',[row({model:'another-model'})],'usage-uncapped-route-model'],
['duplicate',[row(),row()],'usage-duplicate'],
])test(`${name} latches before the test-only halt`,async t=>{
const f=setup(t);
const result=await check(f.opts,{now:()=>NOW,read:()=>records,halt:b=>{assert.ok(readFileSync(join(f.ledger,'stop.json')).length);return f.halt(b);}});
assert.equal(result.reason,reason);assert.equal(f.halts.length,1);
});
test('campaign deadline, immutable owner cap and corrupt/missing ledger all stop without auto reset',async t=>{
for(const variant of ['deadline','raise','malformed','missing']) {
const sub=await t.test(variant,async t=>{
const f=setup(t);let now=NOW;
if(variant==='deadline')now=Date.parse(f.c.end);
if(variant==='raise'){f.c.caps[0].tokens=10;writeFileSync(f.campaign,JSON.stringify(f.c));}
if(variant==='malformed')writeFileSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl'))),'{');
if(variant==='missing')unlinkSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl'))));
const result=await check(f.opts,{now:()=>now,read:()=>[],halt:f.halt});assert.equal(result.stopped,true);assert.equal(f.halts.length,1);
});
}
});
test('halt refusal keeps the durable stop and records an unknown halt, with no resume or raise',async t=>{
const f=setup(t,1);
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:()=>{throw new Error('human-required');}}),/supported-halt-refused/);
assert.equal(status(f.opts).stopped,true);
assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).ok,false);
await check(f.opts,{now:()=>NOW,halt:f.halt});assert.equal(f.halts.length,1);
});
test('usage removal or rewrite is refused rather than refilling or double-counting',async t=>{
const f=setup(t,100);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
const result=await check(f.opts,{now:()=>NOW,read:()=>[row({input:0})],halt:f.halt});assert.equal(result.reason,'usage-rewritten');
const g=setup(t,100);await check(g.opts,{now:()=>NOW,read:()=>[row()],halt:g.halt});
assert.equal((await check(g.opts,{now:()=>NOW,read:()=>[],halt:g.halt})).reason,'usage-disappeared');
});
function insert(f,phase='end',status='known',source='transcript') {
f.store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
row().at,f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t1',source,phase,status,...(status==='known'?[1,1,1,1]:[null,null,null,null]));
const manifest=join(f.dataRoot,'launches',f.c.business,row().run,'bundle');mkdirSync(manifest,{recursive:true,mode:0o700});
writeFileSync(join(manifest,'manifest.json'),JSON.stringify({business:f.c.business,run:row().run,instance:'assistant',provider:'proxy',model:'glm-5.3-flash',proxyRoute:'glm',route:'primary'}),{mode:0o600});
}
test('accounting reads a real store at the current bus schema version',t=>{
const f=setup(t,100);insert(f);
assert.equal(f.store.get("SELECT value FROM meta WHERE key='schema_version'").value,SCHEMA_VERSION);
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
assert.equal(records.length,1);assert.equal(records[0].input,1);
});
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
const f=setup(t,100);
// Only this disposable fixture is altered; the production meta table is append-only.
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
if(version===null) f.store.run("DELETE FROM meta WHERE key='schema_version'");
else f.store.run("UPDATE meta SET value=? WHERE key='schema_version'",version);
const read=()=>readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
if(version==='3s') assert.deepEqual(read(),[]);
else assert.throws(read,/^Error: test-store-schema-refused$/);
});
test('actual read-only bus store source matches token-limit records and excludes proxy telemetry',async t=>{
const f=setup(t,100);insert(f);insert(f,'end','known','@proxy/cliproxy-queue');
const r=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});assert.equal(r.length,1);assert.equal(r[0].route,'glm');
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
assert.equal(f.store.get('SELECT count(*) n FROM usage_records').n,2);
});
test('a completed message with no usage records stops; an in-flight begin waits, an ended unknown turn stops',async t=>{
const f=setup(t,100);insert(f,'begin');
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,false);
f.store.run(`INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,?,?,?)`,'ended',row().at,f.c.business,'session.ended',row().run,'{}');
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown');
const g=setup(t,100);
g.store.run(`INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)`,'t1',row().at,g.c.business,'chief','chief-run','assistant','note','work');
g.store.run(`INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,?,?)`,'t1',row().at,'read',row().run);
assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-missing');
});
test('completed unknown source and fallback manifest fail closed against the real store',async t=>{
const f=setup(t,100);insert(f,'end','unknown');
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown');
const g=setup(t,100);insert(g);
const path=join(g.dataRoot,'launches',g.c.business,row().run,'bundle/manifest.json'),m=JSON.parse(readFileSync(path));m.route='fallback';writeFileSync(path,JSON.stringify(m));
assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-route-drift');
});
test('changed target config never directs a halt to a different root',async t=>{
const f=setup(t,1),other=join(f.root,'other');mkdirSync(other);writeFileSync(join(other,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
const config=JSON.parse(readFileSync(f.config));config.dataRoot=other;writeFileSync(f.config,JSON.stringify(config));
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}));assert.equal(f.halts.length,0);
});
test('normal supported halt refuses an agent environment, preserving its markers',t=>{
const f=setup(t);assert.throws(()=>supportedHalt({config:f.config,dataRoot:f.dataRoot,business:f.c.business},{...process.env,MOSAIC_AGENT_RUN:'test-agent'}),/supported-halt-refused/);
});
test('a corrupt ledger manifest stops with the validated owner campaign, never recreates accounting',async t=>{
const f=setup(t,100);writeFileSync(join(f.ledger,'campaign.json'),'{');
const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'ledger-manifest-invalid');assert.equal(f.halts.length,1);
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),'{');
});
test('an invalid existing stop latch still invokes the supported test halt',async t=>{
const f=setup(t,100);mkdirSync(join(f.ledger,'stop.json'),{mode:0o700});
const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'stop-latch-invalid');assert.equal(f.halts.length,1);
});
test('model-writable campaign paths refuse initialization',t=>{
const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close());
const campaign=join(f.dataRoot,'campaign.json');writeFileSync(campaign,JSON.stringify({version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:1}]}),{mode:0o600});
assert.throws(()=>initialize({campaign,ledger:join(f.root,'ledger')}),/outside/);
});
test('a failed durable-latch write still halts and returns an explicit storage failure',async t=>{
const f=setup(t,1);symlinkSync(join(f.root,'missing-stop-target'),join(f.ledger,'stop.json'));
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/stop-latch-not-durable/);
assert.equal(f.halts.length,1);assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).storageError,'stop-latch-not-durable');
});
test('valid JSON journal token corruption durably latches and halts before accepting another turn',async t=>{
const f=setup(t),deps={now:()=>NOW,read:()=>[row()],halt:f.halt};
await check(f.opts,deps);
const file=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>join(f.ledger,n)).find(n=>readFileSync(n,'utf8').includes('"key"'));
const lines=readFileSync(file,'utf8').trimEnd().split('\n').map(JSON.parse);lines[1].tokens=0;
writeFileSync(file,lines.map(JSON.stringify).join('\n')+'\n');
const result=await check(f.opts,{...deps,read:()=>[row(),row({turn:'t2'})]});
assert.equal(result.reason,'ledger-token-mismatch');assert.equal(f.halts.length,1);
assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-token-mismatch');
});
test('a check killed while holding its lock is halted on restart without reclaiming the lock',async t=>{
const f=setup(t,100);
const code=`import {check} from ${JSON.stringify(new URL('./budget-supervisor.mjs',import.meta.url).href)}; await check(${JSON.stringify(f.opts)},{now:()=>${NOW},read:()=>{process.send('locked');return new Promise(()=>{});}});`;
const child=spawn(process.execPath,['--input-type=module','-e',code],{stdio:['ignore','ignore','pipe','ipc']});
f.after(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'close');}});
await once(child,'message');child.kill('SIGKILL');await once(child,'close');
const lock=join(f.ledger,'check.lock'),ino=lstatSync(lock).ino;
const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('locked accounting must not run'),halt:f.halt});
assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1);
assert.equal(lstatSync(lock).ino,ino);assert.ok(existsSync(join(f.ledger,'stop.json')));
});
test('an existing latch reasserts halt on every locked restart and preserves lock evidence',async t=>{
const f=setup(t,1);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
const lock=join(f.ledger,'check.lock');mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'owner'),'diagnose');
for(let n=0;n<2;n++) assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true);
assert.equal(f.halts.length,3);assert.equal(readFileSync(join(lock,'owner'),'utf8'),'diagnose');
});
test('lock storage failures still durably stop the verified test workload',async t=>{
for(const code of ['EROFS','EACCES']) {
const f=setup(t,100);
const result=await check(f.opts,{now:()=>NOW,halt:b=>{assert.equal(b.dataRoot,f.dataRoot);assert.ok(existsSync(join(f.ledger,'stop.json')));return f.halt(b);},acquireLock:()=>{throw Object.assign(new Error('storage'),{code});}});
assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1);
}
const f=setup(t,100),lock=join(f.ledger,'check.lock');writeFileSync(lock,'not a directory',{mode:0o600});
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true);
assert.equal(readFileSync(lock,'utf8'),'not a directory');assert.equal(f.halts.length,1);
});
test('budget init, check, status and serve refuse unrelated instance metadata before accounting or halt',async t=>{
const f=setup(t,100),other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other);
const config=join(other,'config.json');writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
f.c.instance='unrelated';f.c.config=config;writeFileSync(f.campaign,JSON.stringify(f.c));
assert.throws(()=>initialize({campaign:f.campaign,ledger:join(f.root,'other-ledger')}),/campaign-invalid/);
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;writeFileSync(file,JSON.stringify(p));
await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}),/literal ui797/);
assert.throws(()=>status(f.opts),/campaign-invalid/);
await assert.rejects(main(['serve','--campaign',f.campaign,'--ledger',f.ledger]),/literal ui797/);
assert.equal(f.halts.length,0);
});
test('persisted campaign metadata is compared to the validated owner manifest before accounting',async t=>{
const f=setup(t,100),file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));
p.campaign.caps[0].tokens=101;writeFileSync(file,JSON.stringify(p));
assert.equal((await check(f.opts,{now:()=>NOW,read:()=>assert.fail('changed metadata'),halt:f.halt})).reason,'campaign-changed');
assert.equal(f.halts.length,1);
});
test('absolute cutoff is inclusive; later windows refuse and a persisted extension cannot delay a stop',async t=>{
const end='2026-11-07T06:00:00.000Z',f=setup(t,100,{end});
assert.equal((await check(f.opts,{now:()=>CUTOFF-1,read:()=>[],halt:f.halt})).stopped,false);
assert.equal((await check(f.opts,{now:()=>CUTOFF,read:()=>[],halt:f.halt})).reason,'campaign-absolute-cutoff');
assert.equal(f.halts.length,1);
assert.throws(()=>setup(t,100,{end:'2026-11-08T06:00:00.000Z'}),/campaign-invalid/);
const g=setup(t,100,{end}),file=join(g.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));
p.campaign.end='2026-11-08T06:00:00.000Z';writeFileSync(file,JSON.stringify(p));
assert.equal((await check(g.opts,{now:()=>CUTOFF,halt:g.halt})).reason,'campaign-absolute-cutoff');
assert.equal(g.halts.length,1);
});
test('only the exact proxy/glm identity receives 100M; other approved models remain capped at 10M',t=>{
const cap=(provider,route,model,tokens)=>({provider,route,model,tokens});
const glm=cap('proxy','glm','glm-5.3-flash',100000000),claude=cap('proxy','anthropic','claude-opus-5-5',10000000),codex=cap('proxy','codex','gpt-6-astra',10000000);
for(const c of [glm,claude,codex])assert.ok(setup(t,1,{caps:[c]}));
for(const c of [glm,claude,codex])assert.throws(()=>setup(t,1,{caps:[{...c,tokens:c.tokens+1}]}),/campaign-cap-invalid/);
for(const c of [cap('zai','glm','glm-5.3-flash',100000000),cap('proxy','anthropic','glm-5.3-flash',100000000),cap('proxy','glm','claude-opus-5-5',100000000)])assert.throws(()=>setup(t,1,{caps:[c]}),/campaign-cap-invalid/);
});
test('conflicting route bindings refuse initialization and real-store split-route usage stops',async t=>{
const cap={provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:10000000};
assert.throws(()=>setup(t,1,{caps:[{...cap,route:'glm'},cap]}),/campaign-cap-invalid/);
assert.throws(()=>setup(t,1,{caps:[cap,{...cap}]}),/campaign-cap-invalid/);
const f=setup(t,1,{caps:[cap]});
for(const [i,route] of ['anthropic','glm'].entries()) {
const r=row({run:i?'r123456789abd':'r123456789abc',turn:`t${i}`,model:cap.model,input:6000000,output:0,cacheWrite:0,cacheRead:0});
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',r.at,f.c.business,r.run,r.instance,r.provider,r.model,r.turn,r.source,'end','known',r.input,0,0,0);
const dir=join(f.dataRoot,'launches',f.c.business,r.run,'bundle');mkdirSync(dir,{recursive:true,mode:0o700});
writeFileSync(join(dir,'manifest.json'),JSON.stringify({business:f.c.business,run:r.run,instance:r.instance,provider:r.provider,model:r.model,proxyRoute:route,route:'primary'}),{mode:0o600});
}
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.reason,'usage-uncapped-route-model');assert.equal(f.halts.length,1);
assert.ok(existsSync(join(f.ledger,'stop.json')));
});
test('the authorized GLM and other-model maxima halt at the exact charged ceiling',async t=>{
for(const cap of [{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:100000000},{provider:'proxy',route:'anthropic',model:'claude-sonnet-5-5',tokens:10000000}]) {
const f=setup(t,1,{caps:[cap]});
const r=row({...cap,input:cap.tokens,output:0,cacheWrite:0,cacheRead:0});delete r.tokens;
const result=await check(f.opts,{now:()=>NOW,read:()=>[r],halt:f.halt});
assert.equal(result.reason,'model-cap-reached');assert.equal(result.totals[0].total,cap.tokens);assert.equal(f.halts.length,1);
}
});
for(const variant of ['renamed','missing','mode-0500','cutoff','latched-restart'])test(`unavailable ledger (${variant}) still counts a halt without recreating accounting`,async t=>{
const f=setup(t,1),offline=f.ledger+'-offline',deps={now:()=>NOW,halt:f.halt,read:()=>[row()]};
let priorLatch;
if(variant==='latched-restart') {
await check(f.opts,deps);priorLatch=readFileSync(join(f.ledger,'stop.json'),'utf8');
f.halts.length=0; // Model Resume and a fresh check, without any cached binding.
}
const before=readdirSync(f.ledger).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
if(variant==='mode-0500'){chmodSync(f.ledger,0o500);f.after(()=>chmodSync(f.ledger,0o700));}
else if(variant==='missing')rmSync(f.ledger,{recursive:true});
else renameSync(f.ledger,offline);
let running=true;
const attempts=variant==='latched-restart'?2:1;
for(let n=0;n<attempts;n++) {
await assert.rejects(check(f.opts,{...deps,now:()=>variant==='cutoff'?CUTOFF:NOW,halt:b=>{
assert.equal(running,true);running=false;return f.halt(b);
}}),e=>{
assert.deepEqual(e.result.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
assert.equal(e.result.halt.halted,true);return true;
});
assert.equal(running,false);running=true; // Model another Resume after restarted halt.
}
assert.equal(f.halts.length,attempts);assert.equal(f.halts[0].dataRoot,f.dataRoot);
if(variant==='mode-0500')assert.deepEqual(readdirSync(f.ledger).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
else assert.equal(existsSync(f.ledger),false);
if(priorLatch)assert.equal(readFileSync(join(offline,'stop.json'),'utf8'),priorLatch);
});
test('an unavailable ledger refuses invalid, foreign, changed or unpinned targets before halt',async t=>{
for(const variant of ['invalid','foreign','changed-root','changed-config','changed-campaign','missing-pin']) {
const f=setup(t,1);renameSync(f.ledger,f.ledger+'-offline');
const c=JSON.parse(readFileSync(f.config));
if(variant==='invalid')writeFileSync(f.config,'{');
if(variant==='foreign')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
if(variant==='changed-root') {
const other=join(f.root,'other');mkdirSync(other);writeFileSync(join(other,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
const store=new Store(other);f.after(()=>store.close());c.dataRoot=other;writeFileSync(f.config,JSON.stringify(c));
}
if(variant==='changed-config'){c.execution.model='other';writeFileSync(f.config,JSON.stringify(c));}
if(variant==='changed-campaign'){f.c.business='other-business';writeFileSync(f.campaign,JSON.stringify(f.c));}
if(variant==='missing-pin')unlinkSync(f.campaign+'.binding.json');
await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}));assert.equal(f.halts.length,0,variant);
assert.equal(existsSync(f.ledger),false);
}
});
test('a required modern sidecar cannot be deleted to regain an allowance; unpinned legacy evidence refuses',async t=>{
const f=setup(t,100),manifest=readFileSync(join(f.ledger,'campaign.json'),'utf8');
unlinkSync(f.campaign+'.binding.json');
const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('missing required pin'),halt:f.halt});
assert.equal(result.stopped,true);assert.deepEqual(result.evidenceErrors,['owner-binding-missing']);
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),manifest);
assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,1);
const g=setup(t,100),file=join(g.ledger,'campaign.json'),old=JSON.parse(readFileSync(file));
old.version=1;delete old.configHash;writeFileSync(file,JSON.stringify(old));unlinkSync(g.campaign+'.binding.json');
await assert.rejects(check(g.opts,{now:()=>NOW,halt:g.halt}),/owner-binding-unverified/);
assert.equal(g.halts.length,0);
const h=setup(t,100),hfile=join(h.ledger,'campaign.json'),previous=JSON.parse(readFileSync(hfile));
previous.version=1;delete previous.configHash;writeFileSync(hfile,JSON.stringify(previous));
const before=readFileSync(hfile,'utf8');
assert.equal((await check(h.opts,{now:()=>NOW,halt:h.halt,read:()=>[]})).stopped,false);
assert.equal(readFileSync(hfile,'utf8'),before);
});
test('serve survives unavailable storage, reasserts after Resume each tick and preserves its in-memory stop on recovery',async t=>{
const f=setup(t,100),offline=f.ledger+'-offline',controller=new AbortController(),reports=[];
await check(f.opts,{now:()=>NOW,halt:f.halt,read:()=>[]});
const epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');renameSync(f.ledger,offline);
let running=true;
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:b=>{
assert.equal(running,true);running=false;return f.halt(b);
},report:r=>{
reports.push(r);assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);
running=true; // Model Resume between every tick.
if(reports.length===2)renameSync(offline,f.ledger);
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,3);assert.equal(reports.length,3);
for(const r of reports.slice(0,2))assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch); // Never initialize a new epoch.
assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-storage-unavailable');
});
test('serve keeps retrying a refused halt and refuses targeting if the config changes between ticks',async t=>{
const f=setup(t,1),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:b=>{
f.halts.push(b);throw new Error('modelled halt refusal');
},report:r=>{
reports.push(r);
if(reports.length===2){const c=JSON.parse(readFileSync(f.config));c.execution.model='changed';writeFileSync(f.config,JSON.stringify(c));}
if(reports.length===4)controller.abort();
}});
assert.equal(f.halts.length,2);assert.equal(reports.length,4);
for(const r of reports.slice(0,2))assert.match(r.error,/supported-halt-refused/);
for(const r of reports.slice(2))assert.match(r.error,/campaign-instance-changed/);
assert.equal(existsSync(join(f.ledger,'stop.json')),true);
});
for(const damage of ['missing-ledger','read','parse','mode'])test(`real serve CLI stays alive and attempts supported halt every tick with ${damage} damage`,async t=>{
const f=setup(t,1);
if(damage==='missing-ledger')renameSync(f.ledger,f.ledger+'-offline');else damagePin(f,damage);
const child=spawn(process.execPath,['packages/cli/live/budget-supervisor.mjs','serve','--campaign',f.campaign,'--ledger',f.ledger,'--interval','0.1'],{
env:{...process.env,MOSAIC_AGENT_RUN:'test-unavailable-ledger'},stdio:['ignore','pipe','pipe'],
});
f.after(()=>closeChild(child));
let text='',errors='';
child.stderr.on('data',chunk=>errors+=chunk);
const reports=[];
child.stdout.on('data',chunk=>{
text+=chunk;let end;
while((end=text.indexOf('\n'))!==-1){reports.push(JSON.parse(text.slice(0,end)));text=text.slice(end+1);}
if(reports.length>=3)child.kill('SIGTERM');
});
const timer=setTimeout(()=>child.kill('SIGKILL'),20000);
const [code,signal]=await once(child,'close');clearTimeout(timer);
assert.equal(signal,null,errors);assert.equal(code,0,errors);assert.ok(reports.length>=3,errors);
for(const r of reports) {
assert.equal(r.stopped,true);
if(damage==='missing-ledger'){
assert.equal(r.haltError,'supported-halt-refused-or-unknown');
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
}else{assert.match(r.error,/supported-halt-refused/);assert.deepEqual(r.evidenceErrors,['owner-binding-unreadable-or-invalid']);}
}
assert.equal(existsSync(f.ledger),damage!=='missing-ledger');
});
// Every damaged-sidecar case uses the real usage store and preserves the evidence.
function damagePin(f,kind) {
const pin=f.campaign+'.binding.json';
if(kind==='read') chmodSync(pin,0o000);
if(kind==='parse') writeFileSync(pin,'{');
if(kind==='mode') chmodSync(pin,0o400);
return ()=>{
if(kind==='read'){assert.equal(lstatSync(pin).mode&0o777,0);chmodSync(pin,0o600);}
if(kind==='mode')assert.equal(lstatSync(pin).mode&0o777,0o400);
assert.equal(readFileSync(pin,'utf8'),kind==='parse'?'{':readFileSync(pin+'-original','utf8'));
};
}
for(const kind of ['read','parse','mode']) for(const state of ['restart','cutoff','over-cap','latched-resume'])
test(`sidecar ${kind} damage at ${state} still latches and halts on restart and between service ticks`,async t=>{
for(const running of [false,true]) await t.test(running?'running service':'fresh service',async t=>{
const f=setup(t,state==='over-cap'||state==='latched-resume'?3:100);
insert(f);
const pin=f.campaign+'.binding.json',epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');
writeFileSync(pin+'-original',readFileSync(pin),{mode:0o600});
const now=()=>state==='cutoff'?CUTOFF:NOW;
if(state==='latched-resume')await check(f.opts,{now,halt:f.halt});
let assertDamage;
if(!running)assertDamage=damagePin(f,kind);
const controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now,halt:f.halt,report:r=>{
reports.push(r);
if(running && reports.length===1){assertDamage=damagePin(f,kind);f.halts.length=0;}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.ok(r.evidenceErrors.includes('owner-binding-unreadable-or-invalid'));}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,running?2:state==='latched-resume'?4:3);
assertDamage();assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch);
assert.ok(existsSync(join(f.ledger,'stop.json')));
assert.ok(JSON.parse(readFileSync(join(f.ledger,`halt-${readdirSync(f.ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length}.json`))).evidenceErrors.length);
});
});
test('running service retains validated target evidence when both sidecar and ledger become unavailable',async t=>{
const f=setup(t,100),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{now:()=>NOW,interval:1,signal:controller.signal,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);
if(reports.length===1){writeFileSync(f.campaign+'.binding.json','{');renameSync(f.ledger,f.ledger+'-offline');}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.equal(r.storageErrors.length,2);assert.ok(r.evidenceErrors.length);}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,2);assert.equal(existsSync(f.ledger),false);
});
for(const kind of ['read','parse','mode'])test(`damaged sidecar (${kind}) never permits changed config bytes or foreign marker/root/store`,async t=>{
for(const changed of ['config','invalid','marker','root','store']) {
const f=setup(t,1);damagePin(f,kind);
if(changed==='config')writeFileSync(f.config,readFileSync(f.config,'utf8')+' ');
if(changed==='invalid')writeFileSync(f.config,'{');
if(changed==='marker')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'foreign'}));
if(changed==='root')renameSync(f.dataRoot,f.dataRoot+'-offline');
if(changed==='store')renameSync(join(f.dataRoot,'bus/bus.sqlite'),join(f.dataRoot,'bus/bus.sqlite-offline'));
await assert.rejects(check(f.opts,{now:()=>CUTOFF,halt:f.halt}));assert.equal(f.halts.length,0,changed);
}
});
for(const variant of ['coherent-epoch-edit','zero-pin-hash'])test(`real-store ${variant} stops without accepting a fresh accounting epoch`,async t=>{
const f=setup(t,5);insert(f);
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
const pin=f.campaign+'.binding.json',original=readFileSync(pin,'utf8');
if(variant==='coherent-epoch-edit') {
f.c.start='2026-11-06T02:29:30.000Z';const text=JSON.stringify(f.c);writeFileSync(f.campaign,text);
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;p.hash=createHash('sha256').update(text).digest('hex');writeFileSync(file,JSON.stringify(p));
for(const n of readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')))writeFileSync(join(f.ledger,n),readFileSync(join(f.ledger,n),'utf8').split('\n')[0]+'\n');
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-06T02:29:45.000Z',f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t2','transcript','end','known',4,0,0,0);
assert.equal(f.store.get('SELECT sum(input+output+cache_write+cache_read) total FROM usage_records').total,8);
} else {const p=JSON.parse(original);p.hash='0'.repeat(64);writeFileSync(pin,JSON.stringify(p));}
const before=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.reason,'owner-binding-epoch-changed');assert.equal(f.halts.length,1);
assert.deepEqual(readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
if(variant==='coherent-epoch-edit')assert.equal(readFileSync(pin,'utf8'),original);
assert.throws(()=>status(f.opts),/owner-binding-epoch-changed/);
});
test('semantic sidecar target damage stops using independent config/marker/root/store evidence',async t=>{
for(const field of ['ledger','binding','configHash']) {
const f=setup(t,100),path=f.campaign+'.binding.json',pin=JSON.parse(readFileSync(path));
if(field==='ledger')pin.ledger+='-changed';
if(field==='binding')pin.binding.business='changed-business';
if(field==='configHash')pin.configHash='0'.repeat(64);
const damaged=JSON.stringify(pin);writeFileSync(path,damaged);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('owner-binding-target-changed'));
assert.equal(f.halts.length,1);assert.equal(f.halts[0].business,f.c.business);assert.equal(readFileSync(path,'utf8'),damaged);
}
});
// A conflicting ledger never authorizes its own target (support r4 P1).
function conflictingLedger(t,kind) {
const f=setup(t,100),path=join(f.ledger,'campaign.json');
const g=kind==='foreign'?setup(t,100,{business:'unrelated-business'}):null;
const alter=()=>{
if(g) { writeFileSync(path,readFileSync(join(g.ledger,'campaign.json'))); return; }
const p=JSON.parse(readFileSync(path));p.binding.business='unrelated-business';p.campaign.business='unrelated-business';writeFileSync(path,JSON.stringify(p));
};
const wrong=()=>f.halts.filter(b=>b.business!==f.c.business||b.config!==f.config||b.dataRoot!==f.dataRoot);
return {f,g,alter,wrong};
}
test('a business-only or foreign ledger is replaced by the owner-verified target in check',async t=>{
for(const kind of ['business-only','foreign']) {
const {f,alter,wrong}=conflictingLedger(t,kind);alter();
const result=await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('ledger-target-changed'));
assert.equal(f.halts.length,1);assert.deepEqual(wrong(),[]);
}
});
test('serve startup never retains a conflicting ledger target',async t=>{
for(const kind of ['business-only','foreign']) {
const {f,alter,wrong}=conflictingLedger(t,kind),controller=new AbortController(),reports=[];alter();
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:f.halt,report:r=>{reports.push(r);if(reports.length===3)controller.abort();}});
assert.equal(reports.length,3);assert.ok(f.halts.length>=1);assert.deepEqual(wrong(),[]);
for(const r of reports) assert.ok(r.evidenceErrors.includes('ledger-target-changed'));
}
});
test('a running service halts only its retained owner target when the ledger changes under it',async t=>{
for(const kind of ['business-only','foreign']) {
const {f,alter,wrong}=conflictingLedger(t,kind),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:f.halt,report:r=>{
reports.push(r);if(reports.length===1)alter();if(reports.length===3)controller.abort();
}});
assert.equal(reports[0].stopped,false);
for(const r of reports.slice(1)) { assert.equal(r.stopped,true);assert.ok(r.evidenceErrors.includes('ledger-target-changed')); }
assert.ok(f.halts.length>=1);assert.deepEqual(wrong(),[]);
}
});
test('a conflicting ledger without corroborating owner evidence refuses every halt',async t=>{
for(const kind of ['business-only','foreign']) {
const {f,alter}=conflictingLedger(t,kind);alter();
const pin=f.campaign+'.binding.json',damaged=JSON.parse(readFileSync(pin));damaged.configHash='0'.repeat(64);writeFileSync(pin,JSON.stringify(damaged));
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/campaign-instance-changed/);
assert.equal(f.halts.length,0);
}
});
// A pin damaged to match a damaged ledger, or a foreign pin restored with the
// invoked ledger path, never corroborates that ledger's target (support r5 P1).
function matchingDamage(t,kind) {
const {f,g,alter:alterLedger,wrong}=conflictingLedger(t,kind==='business'?'business-only':'foreign');
const pinPath=f.campaign+'.binding.json',owner=readFileSync(f.campaign,'utf8');
const alter=()=>{
if(kind==='business') { const pin=JSON.parse(readFileSync(pinPath));pin.binding.business='unrelated-business';writeFileSync(pinPath,JSON.stringify(pin)); }
else { const pin=JSON.parse(readFileSync(g.campaign+'.binding.json'));pin.ledger=f.ledger;writeFileSync(pinPath,JSON.stringify(pin)); }
alterLedger();
};
const receipts=dir=>readdirSync(dir).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n));
return {f,g,alter,wrong,owner,receipts};
}
test('matching pin and ledger damage refuses check and serve startup with no halt',async t=>{
for(const kind of ['business','foreign']) {
let m=matchingDamage(t,kind);m.alter();
await assert.rejects(check(m.f.opts,{now:()=>NOW,read:()=>[row()],halt:m.f.halt}),/campaign-instance-changed/);
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);assert.equal(readFileSync(m.f.campaign,'utf8'),m.owner);
m=matchingDamage(t,kind);m.alter();
await assert.rejects(serveBudget(m.f.opts,{interval:1,now:()=>NOW,read:()=>[row()],halt:m.f.halt,report:()=>assert.fail('no tick may run')}),/campaign-instance-changed/);
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);
if(m.g) assert.deepEqual(m.receipts(m.g.ledger),[]);
}
});
test('matching pin and ledger damage under a running service refuses every later tick',async t=>{
for(const kind of ['business','foreign']) {
const m=matchingDamage(t,kind),controller=new AbortController(),reports=[];
await serveBudget(m.f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:m.f.halt,report:r=>{
reports.push(r);if(reports.length===1)m.alter();if(reports.length===3)controller.abort();
}});
assert.equal(reports[0].stopped,false);
for(const r of reports.slice(1)) assert.equal(r.error,'campaign-instance-changed');
assert.equal(m.f.halts.length,0);assert.deepEqual(m.wrong(),[]);assert.deepEqual(m.receipts(m.f.ledger),[]);
}
});
test('a running service keeps its startup ledger when the caller mutates the options object',async t=>{
const f=setup(t,100),replacement=join(f.root,'replacement-ledger'),controller=new AbortController(),reports=[];
const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),opts={...f.opts};let now=NOW;
await serveBudget(opts,{interval:1,signal:controller.signal,now:()=>now,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);
if(reports.length===1) {
mkdirSync(replacement,{mode:0o700});
for(const n of readdirSync(f.ledger)) writeFileSync(join(replacement,n),readFileSync(join(f.ledger,n)),{mode:0o600});
const pin=JSON.parse(readFileSync(f.campaign+'.binding.json'));pin.ledger=replacement;writeFileSync(f.campaign+'.binding.json',JSON.stringify(pin));
opts.ledger=replacement;now=Date.parse(f.c.end);
}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
const names=readdirSync(f.ledger).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)).sort();
assert.deepEqual(names,['halt-1.json','halt-2.json','stop.json']);
const stop=JSON.parse(readFileSync(join(f.ledger,'stop.json')));
assert.equal(stop.reason,'campaign-ended');assert.deepEqual(stop.binding,startup.binding);
assert.deepEqual(readdirSync(replacement).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)),[]);
});
test('an owner business or config retarget refuses without retained evidence and halts the retained target when running',async t=>{
for(const field of ['business','config']) {
const retarget=f=>{const g=setup(t,100,{business:'unrelated-business'}),c=JSON.parse(readFileSync(f.campaign));c[field]=field==='business'?g.c.business:g.config;writeFileSync(f.campaign,JSON.stringify(c));return g;};
let f=setup(t,100),g=retarget(f);
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/campaign-instance-changed/);
assert.equal(f.halts.length,0);assert.equal(g.halts.length,0);
f=setup(t,100);const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);if(reports.length===1)retarget(f);if(reports.length===3)controller.abort();
}});
assert.equal(reports[0].stopped,false);
for(const r of reports.slice(1)) { assert.equal(r.stopped,true);assert.equal(r.reason,'campaign-changed'); }
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
}
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// Deliberately fake identity, real browser code flow and production OIDC validation.
import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
import { chmodSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createServer } from 'node:https';
import { request } from 'node:http';
import { rsaKey, jwt } from '../../webui/tests/helpers/fake-oidc.mjs';
import { checkConsole } from '../../webui/src/sso-config.mjs';
import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, testInstance } from './support-common.mjs';
export const SUBJECT = 'ui797-agent-owner', OWNER = 'ui797-owner';
const ORIGIN = 'https://127.0.0.1:17443', ISSUER = `${ORIGIN}/oidc/`, CLIENT = 'mosaic-ui797', SLOT = 'ui797-local';
export function prepare({ instance, config, owner, runtime, tlsPort }) {
if (owner !== OWNER || String(tlsPort) !== '17443') fail('only ui797-owner and test TLS port 17443 supported');
const { system } = testInstance(instance, config);
outsideRuntime(runtime, system, config);
privateDir(runtime, true); // exclusive: never replace a previous identity or secret
const secretDir = join(dirname(config), 'secrets', 'mosaic-stack');
plainPath(secretDir); mkdirSync(secretDir, { recursive: true, mode: 0o700 });
const secret = randomBytes(32).toString('base64url');
exclusive(join(secretDir, SLOT), secret + '\n');
const openssl = args => {
const r = spawnSync('openssl', args, { cwd: runtime, encoding: 'utf8' });
if (r.status !== 0) fail('test certificate generation failed');
};
// All intermediates stay in the exclusive private runtime, never the repository.
exclusive(join(runtime, 'extensions.cnf'), 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n');
openssl(['req','-x509','-newkey','rsa:2048','-nodes','-days','2','-subj','/CN=ui797 private test CA','-addext','basicConstraints=critical,CA:TRUE','-keyout','ca.key','-out','ca.pem']);
openssl(['req','-new','-newkey','rsa:2048','-nodes','-subj','/CN=127.0.0.1','-keyout','tls.key','-out','tls.csr']);
openssl(['x509','-req','-in','tls.csr','-CA','ca.pem','-CAkey','ca.key','-CAcreateserial','-days','2','-extfile','extensions.cnf','-out','tls.pem']);
for (const name of ['ca.key','ca.pem','tls.key','tls.pem','tls.csr','ca.srl']) chmodSync(join(runtime, name), 0o600);
const signing = rsaKey('ui797-rsa');
exclusive(join(runtime, 'signing.pem'), signing.privateKey.export({ type: 'pkcs8', format: 'pem' }));
const console = checkConsole({ sso: { publicOrigin: ORIGIN, providers: [{ id: SLOT, issuer: ISSUER, clientId: CLIENT, clientSecret: { slot: SLOT }, algs: ['RS256'], subjects: [{ sub: SUBJECT, name: OWNER }] }] } });
exclusive(join(runtime, 'identity.json'), JSON.stringify({ version: 1, instance, config, owner, console }) + '\n');
return { console };
}
export async function serve(runtime, { clock = Date.now } = {}) {
privateDir(runtime);
const state = JSON.parse(readPrivate(join(runtime, 'identity.json')));
const { system } = testInstance(state.instance, state.config);
outsideRuntime(runtime, system, state.config);
if (state.version !== 1 || state.owner !== OWNER || JSON.stringify(state.console) !== JSON.stringify(checkConsole(state.console)) || state.console.sso.publicOrigin !== ORIGIN || state.console.sso.providers.length !== 1 || state.console.sso.providers[0].issuer !== ISSUER || state.console.sso.providers[0].id !== SLOT || state.console.sso.providers[0].clientId !== CLIENT || state.console.sso.providers[0].clientSecret.slot !== SLOT || JSON.stringify(state.console.sso.providers[0].subjects) !== JSON.stringify([{ sub: SUBJECT, name: OWNER }])) fail('test identity metadata refused');
const secret = readPrivate(join(dirname(state.config), 'secrets/mosaic-stack', SLOT)).trim();
const privateKey = createPrivateKey(readPrivate(join(runtime, 'signing.pem')));
const jwk = { ...createPublicKey(privateKey).export({ format: 'jwk' }), kid: 'ui797-rsa', use: 'sig', alg: 'RS256' };
const codes = new Map();
const server = createServer({ key: readPrivate(join(runtime, 'tls.key')), cert: readPrivate(join(runtime, 'tls.pem')) + readPrivate(join(runtime, 'ca.pem')) }, (req, res) => {
res.setHeader('cache-control', 'no-store');
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
if (req.headers.host !== '127.0.0.1:17443') return json(403, { error: 'test_host_required' });
const url = new URL(req.url, ORIGIN);
const path = url.pathname;
if (!path.startsWith('/oidc/')) {
// Fixed loopback upstream, original public Host/Origin and browser cookies intact.
// No subject/authentication headers are manufactured here.
const upstream = request({ hostname: '127.0.0.1', port: 17440, method: req.method, path: req.url, headers: req.headers }, reply => {
res.writeHead(reply.statusCode, reply.headers); reply.pipe(res);
});
upstream.setTimeout(30000, () => upstream.destroy());
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); });
req.on('aborted', () => upstream.destroy()); req.pipe(upstream); return;
}
if (req.method === 'GET' && path === '/oidc/.well-known/openid-configuration') return json(200, {
issuer: ISSUER, authorization_endpoint: `${ISSUER}authorize`, token_endpoint: `${ISSUER}token`, jwks_uri: `${ISSUER}jwks`,
response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_basic'], code_challenge_methods_supported: ['S256'],
});
if (req.method === 'GET' && path === '/oidc/jwks') return json(200, { keys: [jwk] });
if (req.method === 'GET' && path === '/oidc/authorize') {
const q = url.searchParams;
for (const k of new Set(q.keys())) if (q.getAll(k).length !== 1) return json(400, { error: 'invalid_request' });
if (q.get('client_id') !== CLIENT || q.get('redirect_uri') !== `${ORIGIN}/auth/callback` || q.get('response_type') !== 'code' ||
!q.get('scope')?.split(' ').includes('openid') || q.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(q.get('code_challenge') ?? '') ||
!q.get('state') || q.get('state').length > 512 || !q.get('nonce') || q.get('nonce').length > 512 || q.has('login_hint')) return json(400, { error: 'invalid_request' });
const now = clock();
for (const [code, grant] of codes) if (grant.expires <= now) codes.delete(code);
if (codes.size >= 100) return json(429, { error: 'too_many_grants' });
const code = randomBytes(32).toString('base64url');
codes.set(code, { expires: now + 60000, challenge: q.get('code_challenge'), nonce: q.get('nonce') });
const callback = new URL(`${ORIGIN}/auth/callback`); callback.searchParams.set('code', code); callback.searchParams.set('state', q.get('state'));
res.writeHead(302, { location: callback.href }); res.end(); return;
}
if (req.method === 'POST' && path === '/oidc/token') {
let body = '';
req.on('data', c => { body += c; if (body.length > 8192) req.destroy(); });
req.on('end', () => {
let credentials;
try { credentials = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString().split(':').map(decodeURIComponent); } catch { return json(401, { error: 'invalid_client' }); }
if (!req.headers.authorization?.startsWith('Basic ') || credentials.length !== 2 || credentials[0] !== CLIENT || credentials[1] !== secret) return json(401, { error: 'invalid_client' });
const form = new URLSearchParams(body), code = form.get('code'), grant = codes.get(code); codes.delete(code);
if (!grant || grant.expires <= clock() || form.get('grant_type') !== 'authorization_code' || form.get('redirect_uri') !== `${ORIGIN}/auth/callback` ||
createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant' });
const now = Math.floor(clock() / 1000);
const id_token = jwt({ alg: 'RS256', kid: jwk.kid }, { iss: ISSUER, sub: SUBJECT, aud: CLIENT, name: OWNER, nonce: grant.nonce, iat: now, exp: now + 300 }, { key: privateKey });
return json(200, { access_token: randomBytes(32).toString('base64url'), token_type: 'Bearer', expires_in: 300, id_token });
}); return;
}
json(404, { error: 'not_found' });
});
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(17443, '127.0.0.1', resolve); });
return server;
}
export async function main(argv) {
const command = argv.shift(), o = options(argv, command === 'prepare' ? ['--instance','--config','--owner','--runtime','--tls-port'] : ['--runtime']);
if (command === 'prepare') console.log(JSON.stringify(prepare({ instance:o['--instance'], config:o['--config'], owner:o['--owner'], runtime:o['--runtime'], tlsPort:o['--tls-port'] }), null, 2));
else if (command === 'serve') { const server = await serve(o['--runtime']); for (const signal of ['SIGINT','SIGTERM']) process.once(signal, () => { server.closeAllConnections(); server.close(); }); }
else fail('use prepare or serve');
}
if (isMain(import.meta.url)) main(process.argv.slice(2)).catch(() => { console.error('isolated identity refused; check named test config, private runtime and free test ports'); process.exitCode = 1; });
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, verify, createPublicKey } from 'node:crypto';
import { request } from 'node:https';
import { createServer } from 'node:http';
import { readFileSync, statSync, writeFileSync,renameSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { prepare,serve,SUBJECT } from './isolated-identity.mjs';
import { fixture,closeServer,closeChild } from './test-support.mjs';
import { readSlot } from '../../webui/src/sso.mjs';
function setup(t) {
const f=fixture(t),runtime=join(f.root,'identity');
const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime,tlsPort:17443});
return {...f,runtime,output};
}
function http(runtime,path,options={}) {
return new Promise((resolve,reject)=>{
const req=request(`https://127.0.0.1:17443${path}`,{ca:readFileSync(join(runtime,'ca.pem')),...options},res=>{
let body='';res.on('data',c=>body+=c);res.on('end',()=>resolve({status:res.statusCode,headers:res.headers,body}));
});req.on('error',reject);req.end(options.body);
});
}
test('prepare writes a valid SSO block, private CA/keys and a real slot, never rewrites config',t=>{
const f=setup(t),before=readFileSync(f.config,'utf8');
const p=f.output.console.sso.providers[0];
assert.deepEqual(p.subjects,[{sub:SUBJECT,name:'ui797-owner'}]);
assert.ok(readSlot(join(f.root,'mosaic-ui797'),p,{repo:process.cwd()}).length>30);
for(const n of ['ca.key','tls.key','signing.pem','identity.json','ca.pem']) assert.equal(statSync(join(f.runtime,n)).mode&0o777,0o600);
assert.equal(readFileSync(f.config,'utf8'),before);
assert.throws(()=>prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:f.runtime,tlsPort:17443}));
});
test('live/default instances, wrong owner, unclaimed root and unsafe config paths refuse before creating runtime',t=>{
const f=fixture(t);
const defaults={instance:'ui797',config:f.config,owner:'ui797-owner',runtime:join(f.root,'id'),tlsPort:17443};
for(const instance of ['dev','prod','production'])assert.throws(()=>prepare({...defaults,instance}));
assert.throws(()=>prepare({...defaults,owner:'jason'}));
assert.throws(()=>prepare({...defaults,config:join(f.root,'mosaic-dev/config.json')}));
assert.throws(()=>prepare({...defaults,tlsPort:7332}));
assert.throws(()=>prepare({...defaults,config:join(process.cwd(),'packages/cli/live/..test/mosaic-ui797/config.json')}),/outside source/);
writeFileSync(join(f.dataRoot,'instance.json'),'{"instanceVersion":1,"name":"dev"}');
assert.throws(()=>prepare(defaults));
});
test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bounded RS256 token',async t=>{
const f=setup(t),server=await serve(f.runtime);f.after(()=>closeServer(server));
const discovery=await http(f.runtime,'/oidc/.well-known/openid-configuration');assert.equal(discovery.status,200);
const verifier='a'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url');
const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'test-state',nonce:'test-nonce',code_challenge:challenge,code_challenge_method:'S256'});
const auth=await http(f.runtime,'/oidc/authorize?'+q);assert.equal(auth.status,302);
const callback=new URL(auth.headers.location);assert.equal(callback.searchParams.get('state'),'test-state');
const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim();
const tokenOptions={method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64'),'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({grant_type:'authorization_code',code:callback.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()};
const token=await http(f.runtime,'/oidc/token',tokenOptions);assert.equal(token.status,200);
const [head,payload,sig]=JSON.parse(token.body).id_token.split('.'),claims=JSON.parse(Buffer.from(payload,'base64url'));
assert.equal(claims.sub,SUBJECT);assert.equal(claims.nonce,'test-nonce');assert.equal(claims.exp-claims.iat,300);
const jwks=JSON.parse((await http(f.runtime,'/oidc/jwks')).body);
assert.ok(verify('sha256',Buffer.from(head+'.'+payload),createPublicKey({key:jwks.keys[0],format:'jwk'}),Buffer.from(sig,'base64url')));
assert.equal((await http(f.runtime,'/oidc/token',tokenOptions)).status,400);
q.set('redirect_uri','https://mosaic.woltje.com/auth/callback');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400);
q.set('redirect_uri','https://127.0.0.1:17443/auth/callback');q.set('login_hint','jason');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400);q.delete('login_hint');
const second=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location);
const wrong={...tokenOptions,body:new URLSearchParams({grant_type:'authorization_code',code:second.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:'wrong'}).toString()};
assert.equal((await http(f.runtime,'/oidc/token',wrong)).status,400);
assert.equal((await http(f.runtime,'/oidc/jwks',{servername:'127.0.0.1',headers:{host:'mosaic.woltje.com'}})).status,403);
});
test('fixed TLS forwarder preserves normal Console Host, cookies and origin without injected identity',async t=>{
const f=setup(t);let headers;
const upstream=createServer((req,res)=>{headers=req.headers;res.end('Console upstream');});
await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(upstream));
const server=await serve(f.runtime);f.after(()=>closeServer(server));
const r=await http(f.runtime,'/test',{headers:{origin:'https://127.0.0.1:17443',cookie:'fixture=value'}});
assert.equal(r.body,'Console upstream');assert.equal(headers.host,'127.0.0.1:17443');assert.equal(headers.origin,'https://127.0.0.1:17443');assert.equal(headers.cookie,'fixture=value');assert.equal(headers['x-auth-subject'],undefined);
});
test('production Console CLI discovers this OP through NODE_EXTRA_CA_CERTS with ordinary TLS validation',async t=>{
const f=setup(t),config=JSON.parse(readFileSync(f.config));config.console=f.output.console;writeFileSync(f.config,JSON.stringify(config));
const server=await serve(f.runtime);f.after(()=>closeServer(server));
const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(f.runtime,'ca.pem')},stdio:['ignore','pipe','pipe']});
f.after(async()=>{await closeChild(child);});
let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c);
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Console startup: '+output)),15000);child.stdout.on('data',()=>{if(output.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error('Console refused: '+output));});});
const health=JSON.parse((await http(f.runtime,'/healthz')).body);assert.equal(health.sso['ui797-local'],'ready');
});
test('authorization codes expire, and tampering with the single subject refuses service',async t=>{
const f=setup(t);let now=Date.now();
const server=await serve(f.runtime,{clock:()=>now});f.after(()=>closeServer(server));
const verifier='v'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url');
const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'state',nonce:'nonce',code_challenge:challenge,code_challenge_method:'S256'});
const code=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location).searchParams.get('code');now+=60001;
const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim();
const response=await http(f.runtime,'/oidc/token',{method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64')},body:new URLSearchParams({grant_type:'authorization_code',code,redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()});
assert.equal(response.status,400);
const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));state.console.sso.providers[0].subjects=[{sub:'jason',name:'jason'}];writeFileSync(stateFile,JSON.stringify(state));
await assert.rejects(serve(f.runtime),/metadata refused/);
});
test('prepare and persisted serve metadata refuse an otherwise valid unrelated development instance',async t=>{
const f=setup(t),other=join(f.root,'mosaic-unrelated');
renameSync(join(f.root,'mosaic-ui797'),other);
const config=join(other,'config.json');
writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
assert.throws(()=>prepare({instance:'unrelated',config,owner:'ui797-owner',runtime:join(f.root,'other-id'),tlsPort:17443}),/literal ui797/);
const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));
state.instance='unrelated';state.config=config;writeFileSync(stateFile,JSON.stringify(state));
await assert.rejects(serve(f.runtime),/literal ui797/);
});
test('serve revalidates the config directory and current data marker before listening',async t=>{
const f=setup(t);
writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
await assert.rejects(serve(f.runtime),/marker mismatch/);
writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));
const other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other);
state.config=join(other,'config.json');writeFileSync(stateFile,JSON.stringify(state));
await assert.rejects(serve(f.runtime),/matching named test config/);
});
+111
View File
@@ -0,0 +1,111 @@
// Opt-in live acceptance observer/submission. Never starts a host, selects a mode,
// approves a PRD, creates a service resource, or substitutes a scripted role.
import { readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve, relative } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { loadSystem, bootConfig, rolesDir } from '../src/config.mjs';
import { loadBusiness } from '../../business/src/index.mjs';
import { readHostState } from '../src/host.mjs';
import { humanTransport, refuseInsideAgent } from '../src/transport.mjs';
import { staffFor } from '../../bus/src/request-host.mjs';
const fail = message => { throw new Error(message); };
export function parseOptions(argv) {
const result = {};
const flags = { '--authorize-isolated-run': 'authorized', '--submit': 'submit' };
const values = { '--instance':'instance', '--evidence':'evidence', '--config':'config', '--business':'business', '--owner':'owner',
'--request-file':'requestFile', '--request':'request', '--thread':'thread', '--artifact':'artifact', '--timeout-ms':'timeout' };
for (let n = 0; n < argv.length; n++) {
const key = flags[argv[n]] ?? values[argv[n]];
if (!key || key in result) fail('unknown or duplicate option');
result[key] = argv[n] in flags ? true : argv[++n];
if (result[key] === undefined) fail('missing option value');
}
if (!/^[a-z][a-z0-9]{1,15}$/.test(result.instance ?? '') || ['dev','prod','production'].includes(result.instance)) fail('explicit named isolated --instance required');
if (!result.authorized) fail('explicit owner authorization --authorize-isolated-run required');
for (const key of ['config','evidence','requestFile']) if (!isAbsolute(result[key] ?? '')) fail('explicit absolute --' + key + ' required');
if (!result.business || !result.owner) fail('explicit business and expected owner required');
if (result.submit && (!result.thread || result.request)) fail('--submit requires an existing owner --thread and no --request');
result.timeout = result.timeout === undefined ? 300000 : Number(result.timeout);
if (!Number.isSafeInteger(result.timeout) || result.timeout < 1000 || result.timeout > 3600000) fail('timeout must be 1000..3600000 ms');
return result;
}
export function checkResources({ options, system, business, marker, host, tracker }) {
if (system.environment !== 'development') fail('isolated development instance required');
if (marker?.instanceVersion !== 1 || marker.name !== options.instance) fail('instance marker mismatch');
if (business.id !== options.business || business.human !== options.owner) fail('owner/business mismatch');
if (!host?.live || (host.business && host.business !== business.id)) fail('configured live host required; runner does not start one');
if (!Number.isSafeInteger(tracker?.project) || tracker.project < 1) fail('tracker-install: declared installed tracker root required');
const pms = Object.keys(business.roles).filter(r => business.roles[r].definition === 'pm');
if (pms.length !== 1 || !staffFor(business, pms[0])) fail('configured PM, independent technical arbiter, chief, coder and reviewer staff required');
if (!Object.values(business.projects).some(p => p.kind !== 'library')) fail('declared factory source project required');
}
function json(file) {
const stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) fail('bounded regular input required');
return JSON.parse(readFileSync(file, 'utf8'));
}
export async function main(argv, env = process.env) {
const options = parseOptions(argv);
refuseInsideAgent(env); // --owner is an expectation, never authentication or a capability.
const selected = { ...env, MOSAIC_CONFIG: options.config };
const system = loadSystem({ env: selected }), config = bootConfig({ system, businessId: options.business, env: selected });
const business = loadBusiness(options.business, { dir: dirname(options.config), rolesDir: rolesDir(selected) });
const host = readHostState(system.dataRoot);
checkResources({ options, system, business, marker: json(join(system.dataRoot, 'instance.json')), host, tracker: config.trackers?.[options.business] });
for (const [name, role] of Object.entries(business.roles)) {
if (!role.vars?.harness || !role.vars?.model || !role.vars?.thinking) fail('model route missing for ' + name);
}
const source = json(options.requestFile);
if (typeof source.text !== 'string' || !source.text || source.provenance?.raw_runtime_event_exported !== false ||
createHash('sha256').update(source.text).digest('hex') !== source.source_sha256) fail('verified owner-supplied original request quotation required');
const call = humanTransport({ socket: host.socket ?? join(system.dataRoot, 'bus/broker.sock'), business: business.id, env: selected });
await call('instances'); // Existing owner process proof succeeds before any submission.
const credentials = await call('credentials');
if (Array.isArray(credentials) && credentials.some(c => c.service === 'vikunja' && !['valid','ready'].includes(c.state))) fail('tracker credential setup wait; inspect Console credentials');
const rel = relative(system.dataRoot, resolve(options.evidence));
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) fail('evidence output must be outside the instance data root');
mkdirSync(options.evidence, { recursive: true, mode: 0o700 });
if (!lstatSync(options.evidence).isDirectory() || lstatSync(options.evidence).isSymbolicLink()) fail('plain evidence output directory required');
const record = (kind, value) => {
const file = join(options.evidence, `${kind}-${randomUUID()}.json`);
writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); return file;
};
record('preflight', { instance: options.instance, business: business.id, owner: business.human, source,
qualification: 'Owner-supplied incident quote; new runtime submission IDs are recorded separately. Fixture success is not live model acceptance.' });
let request = options.request;
if (options.submit) {
const threads = await call('thread.list');
if (!threads.some(t => t.id === options.thread)) fail('the explicitly selected thread is not owned by this user');
const sent = await call('thread.send', { thread: options.thread, body: source.text });
request = sent.request;
record('submission', { request, message: sent.id, original: { source_event: source.source_event, source_message: source.source_message, source_sha256: source.source_sha256 } });
process.stdout.write(`Request ${request}. Select and confirm the exact plan in the signed-in Console.\n`);
}
if (!request) fail('--submit or an existing --request is required');
const end = Date.now() + options.timeout;
let state, revision = null;
while (Date.now() < end) {
try { state = await call('request.get', { request }); }
catch (e) { if (e.code !== 'request-not-found') throw e; }
if (state && state.revision !== revision) { record('request', state); revision = state.revision; }
if (state?.phase === 'cancelled') fail('request cancelled; receipts retained');
if (state?.phase === 'complete' && state.delivery_evidence) break;
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!state?.delivery_evidence || state.phase !== 'complete') fail('live acceptance incomplete: exact current wait is retained in request evidence');
const delivery = state.delivery_evidence;
const trails = [];
for (const subject of [request, state.source_message, ...state.tasks, delivery.review_request, delivery.verdict]) trails.push({ subject, rows: await call('trail', { subject }) });
record('delivery', { state, trails, verified_local_delivery_only: true });
if (!options.artifact) fail(`download required from signed-in Console /api/requests/${request}/artifact/${delivery.id}; rerun observation with --artifact PATH`);
const bytes = readFileSync(options.artifact);
if (bytes.length !== delivery.artifact.bytes || createHash('sha256').update(bytes).digest('hex') !== delivery.artifact.sha256) fail('download differs from reviewed artifact');
writeFileSync(join(options.evidence, 'reviewed-' + delivery.id + '.html'), bytes, { flag: 'wx', mode: 0o600 });
record('download', { request, delivery: delivery.id, artifact: delivery.artifact, qualification: 'Hash-verified live delivery; lead must run offline behavior checks and inspect real independent role/test receipts before acceptance.' });
process.stdout.write('Live delivery and downloaded bytes retained. Offline browser verification and lead acceptance remain explicit.\n');
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main(process.argv.slice(2)).catch(e => { process.stderr.write(e.message + '\n'); process.exitCode = 2; });
}
+71
View File
@@ -0,0 +1,71 @@
// Owner-run, local test support. No product capability or processing-state writes.
import { constants, closeSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
export const ROOT = fileURLToPath(new URL('../../../', import.meta.url));
export const fail = message => { throw new Error(message); };
export const inside = (parent, path) => { const r = relative(parent, path); return !r || (r !== '..' && !r.startsWith('../') && !isAbsolute(r)); };
export function plainPath(path) {
if (!isAbsolute(path ?? '') || resolve(path) !== path) fail('canonical absolute path required');
let cursor = path;
while (cursor !== dirname(cursor)) {
try { if (lstatSync(cursor).isSymbolicLink()) fail('symbolic link refused'); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
cursor = dirname(cursor);
}
return path;
}
export function privateDir(path, create = false) {
plainPath(path);
if (create) mkdirSync(path, { mode: 0o700 });
const s = lstatSync(path);
if (!s.isDirectory() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o700) fail('private owner directory required');
return path;
}
export function readPrivate(file) {
plainPath(file);
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16 * 1024 * 1024) fail('private bounded owner file required');
return readFileSync(fd, 'utf8');
} finally { closeSync(fd); }
}
export function exclusive(file, value) {
plainPath(file);
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { writeFileSync(fd, value); fsyncSync(fd); } finally { closeSync(fd); }
}
export function options(argv, allowed) {
const out = {};
while (argv.length) {
const key = argv.shift();
if (!allowed.includes(key) || Object.hasOwn(out, key) || !argv.length) fail('unknown, duplicate or missing option');
out[key] = argv.shift();
}
return out;
}
export function testInstance(instance, config) {
if (instance !== 'ui797') fail('literal ui797 test instance required');
plainPath(config);
const copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'mosaic-stack/copies');
if (inside(ROOT, config) || inside(copies, config)) fail('test config and secret slots must be outside source and stack copies');
if (config.split('/').includes('mosaic-dev') || config.split('/').includes('.mosaic-dev') || dirname(config).split('/').at(-1) !== `mosaic-${instance}`) fail('matching named test config required; mosaic-dev refused');
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') fail('TLS verification must remain enabled');
const run = spawnSync(process.execPath, [join(ROOT, 'scripts/mosaic-config.mjs'), 'validate'], { env: { ...process.env, MOSAIC_CONFIG: config }, encoding: 'utf8' });
if (run.status !== 0) fail('test system configuration validation refused');
const system = JSON.parse(run.stdout);
plainPath(system.dataRoot);
if (inside(ROOT, system.dataRoot) || inside(copies, system.dataRoot)) fail('test data root must be outside source and stack copies');
if (system.environment !== 'development' || system.dataRoot.split('/').some(p => ['.mosaic-dev','mosaic-dev'].includes(p))) fail('isolated development data root required');
const marker = JSON.parse(readFileSync(join(system.dataRoot, 'instance.json'), 'utf8'));
if (marker.instanceVersion !== 1 || marker.name !== instance) fail('test instance marker mismatch');
return { system, config: realpathSync(config) };
}
export function outsideRuntime(path, system, config) {
plainPath(path);
if (inside(ROOT, path) || inside(system.dataRoot, path) || inside(dirname(config), path)) fail('runtime must be outside repository, config and model data');
}
export function isMain(meta) { return process.argv[1] && fileURLToPath(meta) === resolve(process.argv[1]); }
+64
View File
@@ -0,0 +1,64 @@
// Focused test fixtures only; runtime CLIs never import this file.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync,readFileSync,readdirSync,readlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function fixture(t) {
if (!process.env.TMPDIR?.startsWith('/mnt/storage/scratch/tmp/') || process.env.TMPDIR.includes('_')) throw new Error('set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX');
const root=mkdtempSync(join(tmpdir(),'ui797-'));
const cleanups=[];
t.after(async()=>{
const owned=ownedProcesses(root);
const errors=[];
for(const cleanup of cleanups.reverse()) try { await cleanup(); } catch(e) { errors.push(e); }
// Preserve evidence if resource shutdown failed; never delete beneath a running service.
const survivors=owned.filter(p=>processAlive(p));
if(survivors.length) errors.push(new Error(`owned processes survived fixture shutdown: ${survivors.map(p=>p.pid)}`));
if(errors.length) throw new AggregateError(errors,'fixture shutdown failed');
rmSync(root,{recursive:true,force:true});
});
const configDir=join(root,'mosaic-ui797'),dataRoot=join(root,'data');
mkdirSync(configDir,{mode:0o700});mkdirSync(dataRoot,{mode:0o700});
const config=join(configDir,'config.json');
writeFileSync(config,JSON.stringify({configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash'}}));
writeFileSync(join(dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
return {root,config,dataRoot,after:fn=>cleanups.push(fn)};
}
export async function closeServer(server) {
const closed=new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));
server.closeAllConnections();
await closed;
}
export async function closeChild(child) {
if(child.exitCode!==null || child.signalCode!==null) return;
const closed=new Promise(resolve=>child.once('close',resolve));
child.kill('SIGTERM');
await closed;
}
// Snapshot only this fixture's processes, including reparented Chromium crash handlers.
export function ownedProcesses(root) {
const processes=readdirSync('/proc').filter(n=>/^\d+$/.test(n)).flatMap(pid=>{
try {
const args=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0');
const stat=readFileSync(`/proc/${pid}/stat`,'utf8').split(' ');
return [{pid:Number(pid),ppid:Number(stat[3]),start:stat[21],args}];
} catch { return []; }
});
const selected=new Set(processes.filter(p=>p.args.some(a=>a===root || a.startsWith(root+'/') || a.startsWith('--user-data-dir='+root+'/'))).map(p=>p.pid));
// Chromium reports PIDs in its own namespace. Never mistake one for a host
// PID (which could belong to another seat) when tracking orphan crash handlers.
for(const p of processes) if(selected.has(p.pid)) for(const a of p.args) if(a.startsWith('--crashpad-handler-pid=')) {
for(const candidate of processes) try {
if(readlinkSync(`/proc/${candidate.pid}/ns/pid`)!==readlinkSync(`/proc/${p.pid}/ns/pid`))continue;
const ids=readFileSync(`/proc/${candidate.pid}/status`,'utf8').match(/^NSpid:\s+(.+)$/m)?.[1].trim().split(/\s+/);
if(Number(ids?.at(-1))===Number(a.split('=')[1]))selected.add(candidate.pid);
} catch {}
}
let size;
do {size=selected.size;for(const p of processes) if(selected.has(p.ppid))selected.add(p.pid);} while(size!==selected.size);
return processes.filter(p=>selected.has(p.pid));
}
export function processAlive(p) {
try { const stat=readFileSync(`/proc/${p.pid}/stat`,'utf8').split(' ');return stat[21]===p.start && stat[2]!=='Z'; } catch {return false;}
}
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env node
// Persistent CDP pipe client, based on webui/tests/browser.mjs. No injected app script.
import { spawn, fork, spawnSync } from 'node:child_process';
import { createServer, createConnection } from 'node:net';
import {createServer as httpServer,request as httpRequest} from 'node:http';
import { once } from 'node:events';
import { createHash, X509Certificate } from 'node:crypto';
import { appendFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs';
const ownedDaemons = new Map();
const HERE = fileURLToPath(import.meta.url);
const INTERACTIVE = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','tab','menuitem','option','spinbutton','slider']);
const delay = ms => new Promise(r => setTimeout(r, ms));
export function testURL(value, origin = null) {
const u = new URL(value, origin ?? undefined);
if (u.hostname !== '127.0.0.1' || !['17440','17443'].includes(u.port) || !['http:','https:'].includes(u.protocol) || u.username || u.password ||
(u.port === '17443' && u.protocol !== 'https:') || (u.port === '17440' && u.protocol !== 'http:')) fail('only loopback test Console ports 17440/17443 allowed');
if (origin && u.origin !== origin) fail('navigation must stay on the selected test origin');
return u;
}
async function chromium(evidence, ca, proxy) {
const cert = new X509Certificate(readPrivate(ca));
if (!cert.ca || Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail('valid private test CA required');
const xdg = join(evidence, 'xdg'); privateDir(xdg, true);
const pki = join(xdg, 'pki'); privateDir(pki, true);
const nss = join(pki, 'nssdb'); privateDir(nss, true);
for (const args of [['-N','--empty-password','-d',`sql:${nss}`], ['-A','-d',`sql:${nss}`,'-n','ui797-private-test-ca','-t','C,,','-i',ca]]) {
const r = spawnSync('certutil', args, { encoding:'utf8' });
if (r.status !== 0) fail('private Chromium CA trust setup failed (certutil required)');
}
for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600);
const profile = join(evidence, 'profile'); privateDir(profile, true);
const executable = process.env.CHROMIUM || '/usr/bin/chromium';
plainPath(executable);
const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage',
'--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic',
`--proxy-server=http://127.0.0.1:${proxy.port}`,'--proxy-bypass-list=<-loopback>',
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--remote-debugging-pipe',
`--user-data-dir=${profile}`];
// Bubblewrap brings up the new namespace's own loopback. There is no veth,
// route, DNS forwarder or host-network FD. Only the fixed proxy has a relay.
// Start with bwrap's empty root. A network namespace does not hide pathname
// Unix sockets: never expose host /run, /tmp, home, devices or the repository.
const system = ['/usr','/etc/ld.so.cache','/etc/nsswitch.conf','/etc/passwd','/etc/group',
'/etc/fonts','/etc/ssl/certs','/etc/localtime'];
const mounts = system.filter(path=>existsSync(path)).flatMap(path=>['--ro-bind',path,path]);
for (const path of ['/bin','/sbin','/lib','/lib64']) if (existsSync(path)) {
mounts.push(...(lstatSync(path).isSymbolicLink() ? ['--symlink',readlinkSync(path),path] : ['--ro-bind',path,path]));
}
const home='/home/browser', runtime='/run/browser';
const environment={PATH:'/usr/bin:/bin',LANG:'C.UTF-8',HOME:home,PWD:home,TMPDIR:'/tmp',
XDG_RUNTIME_DIR:runtime,XDG_DATA_HOME:xdg,XDG_CONFIG_HOME:home+'/.config',XDG_CACHE_HOME:home+'/.cache'};
const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-user','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts',
...mounts,'--dev','/dev','--proc','/proc','--tmpfs','/tmp','--tmpfs','/run','--tmpfs','/home',
'--dir',home,'--dir',runtime,'--bind',evidence,evidence,
'--dir',home+'/.pki','--bind',nss,home+'/.pki/nssdb',
'--ro-bind',process.execPath,process.execPath,
'--ro-bind',fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),'/opt/ui797/browser-network.mjs',
...(executable.startsWith('/usr/') ? [] : ['--ro-bind',executable,executable]),
'--chdir',home,'--clearenv',...Object.entries(environment).flatMap(([key,value])=>['--setenv',key,value]),
'--',process.execPath,'/opt/ui797/browser-network.mjs',readlinkSync('/proc/self/ns/net'),
proxy.socket,String(proxy.port),executable,...chromeArgs],
{ env:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8'}, stdio: ['ignore','ignore','ignore','pipe','pipe'] });
let id = 0, buffer = '';
const pending = new Map(), events = new Map();
const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); };
child.once('error', rejectAll); child.once('exit', rejectAll);
child.stdio[3].on('error', rejectAll); child.stdio[4].on('error', rejectAll);
child.stdio[4].on('data', chunk => {
buffer += chunk.toString(); let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const msg = JSON.parse(buffer.slice(0,end)); buffer = buffer.slice(end+1);
if (msg.method) for (const f of events.get(msg.method) ?? []) f(msg.params, msg.sessionId);
const p = pending.get(msg.id);
if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error('CDP refused ' + p.method)) : p.resolve(msg.result); }
}
});
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error('CDP timeout: ' + method)); }, 15000);
pending.set(key, { resolve, reject, timer, method });
child.stdio[3].write(JSON.stringify({ id:key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0');
});
const on = (method, f) => { if (!events.has(method)) events.set(method,new Set()); events.get(method).add(f); };
let targetId, sessionId;
try {
({ targetId } = await send('Target.createTarget', { url:'about:blank' }));
({ sessionId } = await send('Target.attachToTarget', { targetId, flatten:true }));
} catch (e) {
const closed=once(child,'close');
if (child.exitCode===null && child.signalCode===null) { child.kill('SIGKILL'); await closed; }
throw e;
}
const call = (method, params) => send(method, params, sessionId);
let closing;
return { call, send, on, child, close() {
return closing ??= (async () => {
const exited=once(child,'close');
await send('Browser.close').catch(() => {});
if (child.exitCode === null && child.signalCode === null) await exited;
})();
} };
}
// External transport boundary: Chromium must use this proxy even for loopback.
// It never resolves/opens a socket for an unapproved destination. CDP is defense in depth.
async function originProxy(origin, evidence) {
const allowed=new URL(origin),sockets=new Set();
const track=socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));return socket;};
const server=httpServer((req,res)=>{
let url;
try {url=testURL(req.url,origin);if(url.origin!==origin || allowed.protocol!=='http:')throw Error();}
catch {res.writeHead(403);res.end();return;}
const upstream=httpRequest({hostname:'127.0.0.1',port:Number(allowed.port),method:req.method,path:url.pathname+url.search,headers:req.headers},reply=>{
res.writeHead(reply.statusCode,reply.headers);reply.pipe(res);
});
upstream.on('socket',track);
upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();});
req.on('aborted',()=>upstream.destroy());req.pipe(upstream);
});
server.on('connection',track);
server.on('upgrade',(_req,socket)=>socket.destroy());
server.on('connect',(req,peer,head)=>{
// Only TLS to the selected HTTPS origin may tunnel. WS/other-origin tunnels refuse.
if(allowed.protocol!=='https:' || req.url!==allowed.host){peer.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');return;}
const upstream=track(createConnection({host:'127.0.0.1',port:Number(allowed.port)}));
upstream.once('connect',()=>{peer.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);peer.pipe(upstream);upstream.pipe(peer);});
upstream.on('error',()=>peer.destroy());peer.on('error',()=>upstream.destroy());
peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy());
});
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
const socket=join(evidence,'proxy.sock');
const bridge=createServer(peer=>{
track(peer);
const upstream=track(createConnection({host:'127.0.0.1',port:server.address().port}));
peer.on('error',()=>upstream.destroy());upstream.on('error',()=>peer.destroy());
peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy());
peer.pipe(upstream);upstream.pipe(peer);
});
try {
if(Buffer.byteLength(socket)>100) fail('evidence path too long for Unix socket');
await new Promise((resolve,reject)=>{bridge.once('error',reject);bridge.listen(socket,resolve);});
chmodSync(socket,0o600);
} catch(e) {
const closed=Promise.all([new Promise(resolve=>bridge.close(resolve)),new Promise(resolve=>server.close(resolve))]);
for(const peer of sockets)peer.destroy();await closed;throw e;
}
let closing;
return {port:server.address().port,socket,close(){return closing??=(async()=>{
const closed=Promise.all([new Promise(resolve=>bridge.close(resolve)),new Promise(resolve=>server.close(resolve))]);
for(const socket of sockets)socket.destroy();await closed;
})();}};
}
export async function daemon(evidence) {
privateDir(evidence);
const state = JSON.parse(readPrivate(join(evidence, 'browser.json'))), origin = testURL(state.origin).origin;
let blocked=false;
const proxy=await originProxy(origin,evidence);
let b;
try {
b = await chromium(evidence, state.ca,proxy);
const closeChrome=b.close;
b.close=async()=>{try{await closeChrome();}finally{await proxy.close();}};
let sequence = 0, generation = 0, refs = new Map(), detail = {};
const completed = new Map(), pendingDownloads = new Set();
const downloadsDir = join(evidence,'downloads'); privateDir(downloadsDir, true);
b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); });
b.on('Browser.downloadProgress', e => { if (e.state === 'completed') { completed.set(e.guid, true); pendingDownloads.delete(e.guid); } });
b.on('Page.frameNavigated', e => { if (!e.frame.parentId) { generation++; refs.clear(); } });
// A native alert/confirm/prompt blocks the page (and input, screenshots and the AX tree) until it
// is answered, so the tester answers it with `dialog accept|dismiss`; its type and text are recorded.
let dialog = null, dialogOpened = () => {};
b.on('Page.javascriptDialogOpening', e => { dialog = { type:e.type, message:String(e.message).slice(0,500) }; dialogOpened(); });
b.on('Page.javascriptDialogClosed', () => { dialog = null; });
const refused = async id => {
blocked = true;
await b.call('Fetch.failRequest', { requestId:id, errorReason:'BlockedByClient' }).catch(() => {});
};
// Includes subresources, redirects and popups. No external live Console can be contacted.
b.on('Fetch.requestPaused', e => {
let allowed = false;
try { allowed = testURL(e.request.url).origin === origin; } catch {}
(allowed ? b.call('Fetch.continueRequest', { requestId:e.requestId }) : refused(e.requestId)).catch(() => { blocked = true; });
});
// Deny new targets (including window.open) before they are allowed to run.
b.on('Target.attachedToTarget', e => {
if (e.waitingForDebugger) b.send('Target.closeTarget', { targetId:e.targetInfo.targetId }).catch(() => {});
});
await b.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true });
await b.call('Page.enable'); await b.call('Accessibility.enable'); await b.call('DOM.enable');
await b.call('Network.enable'); await b.call('Network.setBlockedURLs', { urls:['ws://*','wss://*'] });
// Detection latches the session; the forced proxy enforces BEFORE outbound connection.
// Chromium's own background probes are also refused by the proxy, without poisoning UI actions.
b.on('Network.webSocketCreated',e=>{
try {testURL(e.url.replace(/^ws/,'http'),origin);} catch {blocked=true;}
});
await b.call('Fetch.enable', { patterns:[{ urlPattern:'*', requestStage:'Request' }] });
await b.send('Browser.setDownloadBehavior', { behavior:'allowAndName', downloadPath:downloadsDir, eventsEnabled:true });
await b.call('Emulation.setDeviceMetricsOverride', { width:1280, height:1000, deviceScaleFactor:1, mobile:false });
const shot = async name => { const { data } = await b.call('Page.captureScreenshot', { format:'png' }); exclusive(join(evidence,name), Buffer.from(data,'base64')); return name; };
async function snapshot() {
const { nodes } = await b.call('Accessibility.getFullAXTree');
const text = [], elements = [];
const next = new Map();
for (const n of nodes) {
if (n.ignored) continue;
const role = n.role?.value, name = n.name?.value ?? '';
if (role === 'StaticText' && name) text.push(name);
if (INTERACTIVE.has(role) && n.backendDOMNodeId) {
const ref = `r${generation}-${n.backendDOMNodeId}`;
next.set(ref,n.backendDOMNodeId);
// Values (including secret input) never enter snapshots or receipts.
elements.push({ ref, role, name, disabled:n.properties?.some(p => p.name === 'disabled' && p.value?.value === true) ?? false });
}
}
refs = next;
return { text:text.join('\n'), elements };
}
const currentURL = async () => {
const { frameTree } = await b.call('Page.getFrameTree');
if (frameTree.frame.url !== 'about:blank') testURL(frameTree.frame.url, origin);
};
async function focus(ref) {
const backendNodeId = refs.get(ref); if (!backendNodeId) fail('unknown or stale ref; snapshot again');
await b.call('DOM.scrollIntoViewIfNeeded', { backendNodeId });
const { quads } = await b.call('DOM.getContentQuads', { backendNodeId });
const q = quads?.[0]; if (!q || Math.abs((q[2]-q[0])*(q[5]-q[1])) < 1) fail('element has no visible target');
const x = (q[0]+q[2]+q[4]+q[6])/4, y = (q[1]+q[3]+q[5]+q[7])/4;
await b.call('Input.dispatchMouseEvent', { type:'mouseMoved',x,y });
await b.call('Input.dispatchMouseEvent', { type:'mousePressed',x,y,button:'left',clickCount:1 });
// The release does not complete while a dialog it opened is waiting; return when one opens.
const opened = new Promise(resolve => { dialogOpened = resolve; });
const released = b.call('Input.dispatchMouseEvent', { type:'mouseReleased',x,y,button:'left',clickCount:1 });
released.catch(() => {});
try { await Promise.race([released, opened]); } finally { dialogOpened = () => {}; }
}
async function press(key) {
const parts = key.split('+'), base = parts.pop(); let modifiers = 0;
for (const part of parts) {
const mask = { Alt:1, Ctrl:2, Control:2, Meta:4, Shift:8 }[part];
if (!mask || (modifiers & mask)) fail('unsupported key modifier');
modifiers |= mask;
}
let spec = { Enter:['Enter',13,'\r'], Tab:['Tab',9,''], Escape:['Escape',27,''], Backspace:['Backspace',8,''], Delete:['Delete',46,''], Home:['Home',36,''], End:['End',35,''], PageUp:['PageUp',33,''], PageDown:['PageDown',34,''], ArrowDown:['ArrowDown',40,''], ArrowUp:['ArrowUp',38,''], ArrowLeft:['ArrowLeft',37,''], ArrowRight:['ArrowRight',39,''], Space:['Space',32,' '] }[base];
if (!spec && /^[a-zA-Z0-9]$/.test(base)) spec = [/[a-zA-Z]/.test(base) ? `Key${base.toUpperCase()}` : `Digit${base}`,base.toUpperCase().charCodeAt(0),base];
if (!spec) fail('unsupported key');
detail.key=key;
const event = { key:base === 'Space' ? ' ' : base,code:spec[0],windowsVirtualKeyCode:spec[1],modifiers };
await b.call('Input.dispatchKeyEvent', { ...event,type:'keyDown',text:modifiers ? '' : spec[2] });
await b.call('Input.dispatchKeyEvent', { ...event,type:'keyUp' });
}
async function navigate(path) {
const url = testURL(path,origin);
// Authentication query strings are never accepted as CLI inputs or retained.
if (url.pathname === '/auth/callback' || url.pathname.startsWith('/oidc/')) fail('navigate via the Console sign-in control');
const r = await b.call('Page.navigate', { url:url.href });
if (r.errorText) fail('navigation failed');
for (let n=0;n<100;n++) {
const { root } = await b.call('DOM.getDocument');
if (root.childNodeCount > 0) { await delay(100); return; }
await delay(50);
}
fail('navigation timed out');
}
async function perform(command,args) {
switch (command) {
case 'start': await navigate('/'); return snapshot();
case 'goto': if (args.length !== 1) fail('goto PATH'); await navigate(args[0]); return snapshot();
case 'snapshot': if (args.length) fail('snapshot takes no arguments'); return snapshot();
case 'click': {
let ref;
if (args.length === 2 && args[0] === '--text') {
const s = await snapshot(), found = s.elements.filter(e => e.name === args[1] && !e.disabled);
if (found.length !== 1) fail('text must name exactly one enabled interactive element'); ref = found[0].ref;
} else if (args.length === 1) ref = args[0]; else fail('click REF or --text TEXT');
if (/^r[0-9]+-[0-9]+$/.test(ref)) detail.ref = ref; await focus(ref); await delay(150);
if (dialog) { detail.dialog = { ...dialog }; return { dialog:{ ...dialog } }; }
return snapshot();
}
case 'dialog': {
if (args.length !== 1 || !['accept','dismiss'].includes(args[0])) fail('dialog accept|dismiss');
if (!dialog) fail('no dialog is open');
detail.dialog = { ...dialog, answer:args[0] };
await b.call('Page.handleJavaScriptDialog', { accept:args[0] === 'accept' });
dialog = null; await delay(150); return snapshot();
}
case 'type': if (args.length !== 2) fail('type REF TEXT'); if (/^r[0-9]+-[0-9]+$/.test(args[0])) detail.ref=args[0]; await focus(args[0]); await b.call('Input.insertText', { text:args[1] }); return snapshot();
case 'press': if (args.length !== 1) fail('press KEY'); await press(args[0]); await delay(100); return snapshot();
case 'wait': {
const o = options([...args],['--text','--timeout']); const timeout = Number(o['--timeout'] ?? 10);
if (!o['--text'] || !Number.isFinite(timeout) || timeout <= 0 || timeout > 120) fail('wait --text TEXT --timeout 0..120 seconds');
const end = Date.now()+timeout*1000;
do { const s = await snapshot(); if (s.text.includes(o['--text']) || s.elements.some(e => e.name.includes(o['--text']))) return s; await delay(100); } while (Date.now()<end);
fail('text wait timed out'); break;
}
case 'screenshot': {
if (args.length !== 1 || !/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,80}$/.test(args[0])) fail('safe screenshot NAME required');
return { file:await shot(args[0] + '.png') };
}
case 'downloads': {
const end = Date.now()+10000;
while (pendingDownloads.size && Date.now()<end) await delay(50);
if (pendingDownloads.size) fail('download still pending');
return [...completed.keys()].map(name => { const file = join(downloadsDir,name), bytes = readFileSync(file); chmodSync(file,0o600); return { file, bytes:bytes.length, sha256:createHash('sha256').update(bytes).digest('hex') }; });
}
case 'stop': return { stopped:true };
default: fail('unknown browser command');
}
}
async function action(command,args) {
detail = {};
// No screenshot can be taken while a dialog blocks the page; other commands are refused and journaled.
const n = String(++sequence).padStart(5,'0'), before = dialog ? null : await shot(`${n}-before.png`);
let result, error, after;
try {
if (dialog && command !== 'dialog' && command !== 'stop') fail(`a ${dialog.type} dialog is open; dialog accept|dismiss`);
// The frame tree is unavailable while a dialog is open; the page cannot navigate until it closes.
if (command !== 'stop' && !dialog) await currentURL(); result = await perform(command,args);
if (command !== 'stop') { if (!dialog) await currentURL(); if (blocked) fail('non-test network request blocked; stop session'); } }
catch (e) { error = e.message; }
finally {
after = dialog ? null : await shot(`${n}-after.png`).catch(() => null);
// No text input, URLs, auth codes, tokens, cookie/header/body values in the journal.
appendFileSync(join(evidence,'actions.jsonl'), JSON.stringify({ sequence,at:new Date().toISOString(),command,...detail,before,after,ok:!error,error:error ?? null })+'\n', { mode:0o600 });
}
if (error) throw new Error(error);
return result;
}
let tail = Promise.resolve(), stopping = false;
const socket = join(evidence,'browser.sock');
if (Buffer.byteLength(socket)>100) fail('evidence path too long for Unix socket');
const server = createServer(peer => {
peer.on('error', () => {});
peer.setTimeout(150000, () => peer.destroy()); let data = '';
peer.on('data', chunk => {
data += chunk; if (data.length > 65536) return peer.destroy();
if (!data.includes('\n')) return;
peer.removeAllListeners('data');
tail = tail.then(async () => {
let request;
try {
request = JSON.parse(data); if (!Array.isArray(request.args) || request.args.some(a => typeof a !== 'string') || stopping) fail('bad browser command');
const value = await action(request.command,request.args);
if (request.command === 'stop') { stopping = true; server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); }
peer.end(JSON.stringify({ ok:true,value })+'\n');
} catch (e) { peer.end(JSON.stringify({ ok:false,error:e.message })+'\n'); }
});
});
});
await new Promise((resolve,reject) => { server.once('error',reject); server.listen(socket,resolve); }); chmodSync(socket,0o600);
const cleanup = async () => { server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); };
for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => cleanup().finally(() => process.exit(0)));
return { server, cleanup };
} catch (e) { await b?.close().catch(() => {}); await proxy.close(); throw e; }
}
export async function callBrowser(evidence, command, args=[]) {
privateDir(evidence); readPrivate(join(evidence,'browser.json'));
const value=await new Promise((resolve,reject) => {
const peer = createConnection(join(evidence,'browser.sock')); let data = '';
peer.on('error', () => {});
peer.setTimeout(150000, () => peer.destroy(new Error('browser action timed out; inspect receipts before retry')));
peer.on('error',reject); peer.on('connect', () => peer.write(JSON.stringify({ command,args })+'\n'));
peer.on('data',chunk => { data += chunk; }); peer.on('end', () => { try { const r = JSON.parse(data); r.ok ? resolve(r.value) : reject(new Error(r.error)); } catch(e) { reject(e); } });
});
if (command === 'stop' && ownedDaemons.has(evidence)) await ownedDaemons.get(evidence).exited;
return value;
}
export async function start({ evidence, origin, ca }) {
if (testURL(origin).origin !== origin) fail('origin must have no path, query or fragment');
plainPath(ca); readPrivate(ca); plainPath(evidence); privateDir(evidence,true);
exclusive(join(evidence,'browser.json'),JSON.stringify({ version:1,origin,ca })+'\n');
const child = fork(HERE,['--daemon',evidence],{ detached:true,stdio:['ignore','ignore','ignore','ipc'] });
const exited=new Promise(resolve=>child.once('exit',resolve));
ownedDaemons.set(evidence,{child,exited});
exited.then(()=>ownedDaemons.delete(evidence));
try {
await new Promise((resolve,reject) => {
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('browser daemon startup timed out')); },20000);
child.once('error',reject); child.once('exit', () => { clearTimeout(timer); reject(new Error('browser daemon failed to start')); });
child.once('message',m => { clearTimeout(timer); m.ok ? resolve() : reject(new Error(m.error)); });
});
} catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); await exited; throw e; }
child.disconnect(); child.unref();
try { return await callBrowser(evidence,'start'); }
catch(e) {
await callBrowser(evidence,'stop').catch(async()=>{child.kill('SIGTERM');await exited;});
throw e;
}
}
export async function main(argv, env=process.env) {
const command = argv.shift();
if (command === '--daemon') { await daemon(argv[0]); process.send?.({ok:true}); return; }
if (command === 'start') {
const o = options(argv,['--evidence','--origin','--ca']); return start({evidence:o['--evidence'],origin:o['--origin'],ca:o['--ca']});
}
let evidence = env.UI797_EVIDENCE;
const index = argv.indexOf('--evidence');
if (index >= 0) { evidence = argv[index+1]; argv.splice(index,2); }
return callBrowser(evidence,command,argv);
}
if (isMain(import.meta.url)) main(process.argv.slice(2)).then(r => { if (r !== undefined) console.log(JSON.stringify(r,null,2)); }).catch(e => { if (process.send) process.send({ok:false,error:'browser daemon startup refused'}); else console.error(e.message); process.exitCode=1; });
+279
View File
@@ -0,0 +1,279 @@
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createServer as tcpServer,createConnection } from 'node:net';
import { createSocket } from 'node:dgram';
import { readFileSync, readdirSync, existsSync,writeFileSync,lstatSync,readlinkSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { start,callBrowser,testURL } from './ui-browser.mjs';
import { prepare,serve } from './isolated-identity.mjs';
import { fixture,closeServer,closeChild,ownedProcesses } from './test-support.mjs';
const pause=ms=>new Promise(r=>setTimeout(r,ms));
test('browser URL guard refuses live, remote, credentials, non-test ports and origin changes',()=>{
for(const u of ['http://127.0.0.1:7332','https://mosaic.woltje.com','http://10.1.1.53:17440','http://localhost:17440','http://u:[email protected]:17440','https://127.0.0.1:17440','http://127.0.0.1:17443'])assert.throws(()=>testURL(u));
assert.equal(testURL('/chat','http://127.0.0.1:17440').pathname,'/chat');
assert.throws(()=>testURL('https://127.0.0.1:17443','http://127.0.0.1:17440'));
});
async function setup(t,html) {
const f=fixture(t),identity=join(f.root,'id');
prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443});
const hits=[];
const server=createServer((req,res)=>{
hits.push(req.url);
if(req.url==='/download'){res.writeHead(200,{'content-disposition':'attachment; filename="product.html"','content-type':'text/html'});res.end('<h1>offline artifact</h1>');return;}
if(req.url==='/redirect'){res.writeHead(302,{location:'http://127.0.0.1:7332/live'});res.end();return;}
res.setHeader('content-type','text/html');res.end(html);
});
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(server));
const evidence=join(f.root,'browser');
f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');});
return {...f,identity,evidence,hits};
}
test('real Chromium keeps a session across CLI processes; mouse/keyboard refs, screenshots and actual downloads',async t=>{
const f=await setup(t,`<!doctype html><title>fixture</title><h1>Browser fixture</h1><input aria-label="Request"><button onclick="document.querySelector('output').textContent=document.querySelector('input').value">Send</button><output></output><a href="/download">Download reviewed HTML</a>`);
await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});
const snapshot=await callBrowser(f.evidence,'snapshot');assert.match(snapshot.text,/Browser fixture/);
const input=snapshot.elements.find(e=>e.name==='Request');assert.ok(input);
const again=await callBrowser(f.evidence,'snapshot');assert.equal(again.elements.find(e=>e.name==='Request').ref,input.ref);
await callBrowser(f.evidence,'type',[input.ref,'discard this']);
await callBrowser(f.evidence,'press',['Ctrl+A']);
await callBrowser(f.evidence,'press',['Backspace']);
await callBrowser(f.evidence,'type',[input.ref,'Pomodoro from keyboard']);
await callBrowser(f.evidence,'click',['--text','Send']);
await callBrowser(f.evidence,'wait',['--text','Pomodoro from keyboard','--timeout','2']);
const cli=spawn(process.execPath,['packages/cli/live/ui-browser.mjs','snapshot','--evidence',f.evidence],{stdio:['ignore','pipe','pipe']});let out='',err='';cli.stdout.on('data',c=>out+=c);cli.stderr.on('data',c=>err+=c);
const code=await new Promise(r=>cli.once('exit',r));assert.equal(code,0,err);assert.match(out,/Pomodoro from keyboard/);
await callBrowser(f.evidence,'screenshot',['fixture']);
assert.ok(readFileSync(join(f.evidence,'fixture.png')).length>1000);
await callBrowser(f.evidence,'click',['--text','Download reviewed HTML']);
await pause(300);
const downloads=await callBrowser(f.evidence,'downloads');assert.equal(downloads.length,1);assert.equal(downloads[0].sha256.length,64);assert.equal(readFileSync(downloads[0].file,'utf8'),'<h1>offline artifact</h1>');
await assert.rejects(callBrowser(f.evidence,'goto',['http://127.0.0.1:7332']),/only loopback/);
await assert.rejects(callBrowser(f.evidence,'click',['bad-ref']),/stale ref/);
await assert.rejects(callBrowser(f.evidence,'wait',['--text','missing','--timeout','0.2']),/timed out/);
await callBrowser(f.evidence,'press',['Tab']);await callBrowser(f.evidence,'stop');
const receipts=readFileSync(join(f.evidence,'actions.jsonl'),'utf8').trim().split('\n').map(JSON.parse);
assert.equal(receipts.length,18);assert.ok(receipts.some(r=>r.ok===false));
for(const r of receipts){assert.ok(existsSync(join(f.evidence,r.before)));assert.ok(existsSync(join(f.evidence,r.after)));}
assert.equal(receipts.map(r=>r.sequence).join(','),Array.from({length:18},(_,i)=>i+1).join(','));
assert.ok(!JSON.stringify(receipts).includes('Pomodoro from keyboard'));
assert.ok(readdirSync(join(f.evidence,'profile')).length>0);
});
test('a click that opens a native confirm returns it; dialog accept|dismiss answers it and other commands refuse meanwhile',async t=>{
const f=await setup(t,`<!doctype html><h1>Dialog fixture</h1><button onclick="document.querySelector('output').textContent=confirm('Resume processing?')?'accepted':'dismissed'">Resume</button><output>idle</output>`);
await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});
await assert.rejects(callBrowser(f.evidence,'dialog',['accept']),/no dialog is open/);
const opened=await callBrowser(f.evidence,'click',['--text','Resume']);
assert.deepEqual(opened,{dialog:{type:'confirm',message:'Resume processing?'}});
await assert.rejects(callBrowser(f.evidence,'snapshot'),/confirm dialog is open/);
await assert.rejects(callBrowser(f.evidence,'dialog',['maybe']),/dialog accept\|dismiss/);
const accepted=await callBrowser(f.evidence,'dialog',['accept']);assert.match(accepted.text,/accepted/);
await callBrowser(f.evidence,'click',['--text','Resume']);
const dismissed=await callBrowser(f.evidence,'dialog',['dismiss']);assert.match(dismissed.text,/dismissed/);
await callBrowser(f.evidence,'stop');
const receipts=readFileSync(join(f.evidence,'actions.jsonl'),'utf8').trim().split('\n').map(JSON.parse);
const rows=receipts.slice(-8).map(r=>[r.command,r.ok,r.dialog?.type??null,r.dialog?.answer??null,!!r.before,!!r.after]);
assert.deepEqual(rows,[
['dialog',false,null,null,true,true],
['click',true,'confirm',null,true,false],
['snapshot',false,null,null,false,false],
['dialog',false,null,null,false,false],
['dialog',true,'confirm','accept',false,true],
['click',true,'confirm',null,true,false],
['dialog',true,'confirm','dismiss',false,true],
['stop',true,null,null,true,true],
]);
assert.equal(receipts.find(r=>r.dialog?.answer==='accept').dialog.message,'Resume processing?');
for(const r of receipts)for(const file of [r.before,r.after])if(file)assert.ok(existsSync(join(f.evidence,file)));
});
test('redirects to the live port are blocked before a request reaches it',async t=>{
const f=await setup(t,'<!doctype html><h1>safe</h1><a href="/redirect">Escape</a>');
await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});
await assert.rejects(callBrowser(f.evidence,'click',['--text','Escape']),/blocked|only loopback/);
assert.ok(f.hits.includes('/redirect'));
assert.ok(readFileSync(join(f.evidence,'actions.jsonl'),'utf8').includes('false'));
});
test('private CA browser performs the actual authorization redirect into production Console SSO',async t=>{
const {writeFileSync}=await import('node:fs');
const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser');
const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443});
const config=JSON.parse(readFileSync(f.config));config.console=output.console;writeFileSync(f.config,JSON.stringify(config));
const idp=await serve(identity);f.after(()=>closeServer(idp));
const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(identity,'ca.pem')},stdio:['ignore','pipe','pipe']});let text='';child.stdout.on('data',c=>text+=c);child.stderr.on('data',c=>text+=c);
f.after(()=>closeChild(child));
f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');});
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error(text)),15000);child.stdout.on('data',()=>{if(text.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error(text));});});
const initial=await start({evidence,origin:'https://127.0.0.1:17443',ca:join(identity,'ca.pem')});
// The sole provider signs in immediately through redirects during start.
const signedIn=await callBrowser(evidence,'wait',['--text','Sign out','--timeout','10']);
assert.ok(signedIn.elements.some(e=>e.name==='Sign out'));
assert.ok(!text.includes('sso refused'));
assert.ok(!readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('code='));
});
test('a different private CA is refused by Chromium with TLS verification enabled',async t=>{
const f=fixture(t),g=fixture(t),id=join(f.root,'id'),other=join(g.root,'id'),evidence=join(f.root,'browser');
for(const [config,runtime] of [[f.config,id],[g.config,other]])prepare({instance:'ui797',config,owner:'ui797-owner',runtime,tlsPort:17443});
const server=await serve(other);f.after(()=>closeServer(server));
f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');});
await assert.rejects(start({evidence,origin:'https://127.0.0.1:17443',ca:join(id,'ca.pem')}),/navigation failed/);
assert.ok(readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('false'));
});
for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`Chromium ${origin} blocks WebSocket handshakes before the forbidden listener sees any TCP connection`,async t=>{
let connections=0,upgrades=0;
const outside=createServer((_req,res)=>res.end('forbidden'));
outside.on('connection',()=>connections++);
outside.on('upgrade',(_req,socket)=>{upgrades++;socket.destroy();});
await new Promise((resolve,reject)=>{outside.once('error',reject);outside.listen(17442,'127.0.0.1',resolve);});
t.after(()=>closeServer(outside));
const f=await setup(t,`<!doctype html><h1>safe</h1><button onclick="window.ws=new WebSocket('ws://127.0.0.1:17442/nope');window.wss=new WebSocket('wss://127.0.0.1:17442/nope')">WebSocket</button>`);
if(origin.startsWith('https:')) {const server=await serve(f.identity);f.after(()=>closeServer(server));}
await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')});
await assert.rejects(callBrowser(f.evidence,'click',['--text','WebSocket']),/blocked/);
await pause(300);
assert.equal(connections,0);assert.equal(upgrades,0);
await callBrowser(f.evidence,'stop');
});
test('a failing whole test file shuts down its browser and servers before removing fixture roots',async t=>{
const f=fixture(t),file=join(f.root,'failure.test.mjs');
const url=name=>JSON.stringify(new URL(name,import.meta.url).href);
writeFileSync(file,`import test from 'node:test';
import {createServer} from 'node:http';
import {join} from 'node:path';
import {fixture,closeServer} from ${url('./test-support.mjs')};
import {prepare} from ${url('./isolated-identity.mjs')};
import {start,callBrowser} from ${url('./ui-browser.mjs')};
test('intentional failure after browser startup',async t=>{
const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser');
console.log('FIXTURE_ROOT='+f.root);
prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443});
const server=createServer((req,res)=>res.end('<h1>failure cleanup</h1>'));
await new Promise(r=>server.listen(17440,'127.0.0.1',r));f.after(()=>closeServer(server));
f.after(()=>callBrowser(evidence,'stop'));
await start({evidence,origin:'http://127.0.0.1:17440',ca:join(identity,'ca.pem')});
throw new Error('expected-cleanup-failure');
});`);
const child=spawn(process.execPath,['--test','--test-reporter=tap',file],{env:{...process.env,NODE_TEST_CONTEXT:undefined},stdio:['ignore','pipe','pipe']});
f.after(()=>closeChild(child));
let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c);
const code=await new Promise(resolve=>child.once('close',resolve));
assert.equal(code,1,output);assert.match(output,/expected-cleanup-failure/);
const root=output.match(/FIXTURE_ROOT=(\S+)/)?.[1];assert.ok(root,output);
assert.equal(existsSync(root),false,output);assert.deepEqual(ownedProcesses(root),[]);
});
for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`Chromium ${origin} emits no STUN/TURN UDP or TCP to host loopback listeners`,async t=>{
const targets=[],counters=[];
// Each protocol/address has its own independent host listener, outside bwrap.
for(const host of ['127.0.0.1','::1','127.0.0.2']) {
const address=host.includes(':')?`[${host}]`:host;
for(const scheme of ['stun','turn']) {
const udp=createSocket(host.includes(':')?'udp6':'udp4'),count={host,scheme,datagrams:0};counters.push(count);
udp.on('message',()=>count.datagrams++);
await new Promise((resolve,reject)=>{udp.once('error',reject);udp.bind(0,host,resolve);});
count.port=udp.address().port;
t.after(()=>new Promise(resolve=>udp.close(resolve)));
targets.push({urls:`${scheme}:${address}:${udp.address().port}${scheme==='turn'?'?transport=udp':''}`,username:'test',credential:'test'});
}
const tcp=tcpServer(socket=>{count.connections++;socket.destroy();}),count={host,connections:0};counters.push(count);
await new Promise((resolve,reject)=>{tcp.once('error',reject);tcp.listen(host==='127.0.0.1'?17442:0,host,resolve);});
count.port=tcp.address().port;
t.after(()=>new Promise(resolve=>tcp.close(resolve)));
targets.push({urls:`turn:${address}:${tcp.address().port}?transport=tcp`,username:'test',credential:'test'});
}
const f=await setup(t,`<!doctype html><h1>ICE boundary</h1><button onclick="go()">Send ICE</button><output>idle</output><script>
const pcs=[];async function go(){for(const server of ${JSON.stringify(targets)}){const pc=new RTCPeerConnection({iceServers:[server]});pcs.push(pc);pc.createDataChannel('probe');await pc.setLocalDescription(await pc.createOffer());}document.querySelector('output').textContent='9 ICE agents started';}</script>`);
if(origin.startsWith('https:')){const id=await serve(f.identity);f.after(()=>closeServer(id));}
await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')});
assert.equal(lstatSync(join(f.evidence,'browser.sock')).mode&0o777,0o600);
assert.equal(lstatSync(join(f.evidence,'proxy.sock')).mode&0o777,0o600);
const processes=ownedProcesses(f.root).filter(p=>p.args.includes('--user-data-dir='+f.evidence+'/profile'));
assert.ok(processes.length);
for(const p of processes.filter(p=>!p.args.includes('--unshare-net')))assert.notEqual(readlinkSync(`/proc/${p.pid}/ns/net`),readlinkSync('/proc/self/ns/net'));
await callBrowser(f.evidence,'click',['--text','Send ICE']);
await callBrowser(f.evidence,'wait',['--text','9 ICE agents started','--timeout','5']);
await pause(2500);
await callBrowser(f.evidence,'stop');
t.diagnostic(JSON.stringify({origin,listeners:counters}));
for(const count of counters)assert.equal(count.datagrams??count.connections,0,JSON.stringify(count));
});
for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`actual Chromium ${origin} cannot reach parent pathname D-Bus`,async t=>{
const f=await setup(t,'<!doctype html><h1>D-Bus boundary</h1>');
if(origin.startsWith('https:')){const id=await serve(f.identity);f.after(()=>closeServer(id));}
const path=join(f.root,'dbus.sock');let connections=0,auth=0;
const bus=tcpServer(peer=>{connections++;peer.on('error',()=>{});peer.on('data',data=>{if(data.includes(Buffer.from('AUTH EXTERNAL')))auth++;peer.destroy();});peer.setTimeout(100,()=>peer.destroy());});
await new Promise((r,j)=>{bus.once('error',j);bus.listen(path,r);});f.after(()=>new Promise(r=>bus.close(r)));
// Prove the fixture is reachable from the host before asserting zero traffic.
await new Promise((r,j)=>{const p=createConnection(path);p.once('error',j);p.once('connect',()=>p.end('AUTH EXTERNAL positive control'));p.on('data',()=>{});p.once('close',r);});
assert.equal(connections,1);assert.equal(auth,1);connections=0;auth=0;
const saved=process.env.DBUS_SESSION_BUS_ADDRESS;
process.env.DBUS_SESSION_BUS_ADDRESS='unix:path='+path;
try {await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')});await pause(500);await callBrowser(f.evidence,'stop');}
finally {if(saved===undefined)delete process.env.DBUS_SESSION_BUS_ADDRESS;else process.env.DBUS_SESSION_BUS_ADDRESS=saved;}
t.diagnostic(JSON.stringify({origin,connections,auth}));assert.equal(connections,0);assert.equal(auth,0);
});
test('CHROMIUM diagnostic sees only browser mounts and explicit environment; host sockets and a 0600 file stay hidden',async t=>{
const f=await setup(t,'<!doctype html><h1>Native mount boundary</h1>');
const path=join(f.root,'host-agent.sock'),abstract='\0ui797-native-'+process.pid;
let pathnameHits=0,abstractHits=0;
for(const [address,count] of [[path,()=>pathnameHits++],[abstract,()=>abstractHits++]]) {
const server=tcpServer(peer=>{count();peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{server.once('error',j);server.listen(address,r);});f.after(()=>new Promise(r=>server.close(r)));
await new Promise((r,j)=>{const p=createConnection(address);p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
}
assert.equal(pathnameHits,1);assert.equal(abstractHits,1);pathnameHits=abstractHits=0;
let tcpHits=0,udpHits=0;
const tcp=tcpServer(peer=>{tcpHits++;peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{tcp.once('error',j);tcp.listen(0,'127.0.0.1',r);});f.after(()=>new Promise(r=>tcp.close(r)));
await new Promise((r,j)=>{const p=createConnection({host:'127.0.0.1',port:tcp.address().port});p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
const udp=createSocket('udp4');udp.on('message',()=>udpHits++);
await new Promise(r=>udp.bind(0,'127.0.0.1',r));f.after(()=>new Promise(r=>udp.close(r)));
await new Promise(r=>udp.send('positive',udp.address().port,'127.0.0.1',r));await pause(50);
assert.equal(tcpHits,1);assert.equal(udpHits,1);tcpHits=udpHits=0;
const secret=join(f.root,'synthetic-credential');writeFileSync(secret,'synthetic fixture only',{mode:0o600});
assert.equal(readFileSync(secret,'utf8'),'synthetic fixture only');
const output=join(f.evidence,'native.json'),wrapper=join(f.root,'chromium-diagnostic.mjs');
// The supported override is a single read-only executable. It needs no host
// script directory or inherited variables, then starts the actual Chromium.
writeFileSync(wrapper,`#!${process.execPath}
import {createConnection} from 'node:net';
import {createSocket} from 'node:dgram';
import {readFileSync,writeFileSync,existsSync,readlinkSync,closeSync} from 'node:fs';
import {spawn} from 'node:child_process';
const hostNet=${JSON.stringify(readlinkSync('/proc/self/ns/net'))};
if(readlinkSync('/proc/self/ns/net')===hostNet)throw Error('network namespace missing');
const attempts=[];
for(const path of ${JSON.stringify([path,abstract,{host:'127.0.0.1',port:tcp.address().port}])})attempts.push(await new Promise(resolve=>{
const p=createConnection(path);p.setTimeout(300,()=>p.destroy());p.on('connect',()=>{p.end('unexpected');resolve('connected');});p.on('error',e=>resolve(e.code));p.on('close',()=>resolve('closed'));
}));
const udp=createSocket('udp4');await new Promise(r=>udp.send('negative',${udp.address().port},'127.0.0.1',()=>{udp.close();r();}));
let file;try{readFileSync(${JSON.stringify(secret)});file='readable';}catch(e){file=e.code;}
writeFileSync(${JSON.stringify(output)},JSON.stringify({attempts,file,environment:process.env,hostRoot:existsSync(${JSON.stringify(f.config)}),hostRuntime:existsSync('/run/dbus/system_bus_socket'),privateHome:process.env.HOME==='/home/browser',privateRuntime:process.env.XDG_RUNTIME_DIR==='/run/browser',nss:existsSync(process.env.HOME+'/.pki/nssdb/cert9.db')}));
const child=spawn('/usr/bin/chromium',process.argv.slice(2),{stdio:['ignore','ignore','ignore',3,4]});closeSync(3);closeSync(4);
child.once('exit',(code)=>{process.exitCode=code;});
`,{mode:0o700});
const names=['CHROMIUM','DBUS_SESSION_BUS_ADDRESS','SSH_AUTH_SOCK','XDG_RUNTIME_DIR','UI797_SYNTHETIC_SECRET'];
const saved=Object.fromEntries(names.map(n=>[n,process.env[n]]));
Object.assign(process.env,{CHROMIUM:wrapper,DBUS_SESSION_BUS_ADDRESS:'unix:path='+path,SSH_AUTH_SOCK:path,XDG_RUNTIME_DIR:f.root,UI797_SYNTHETIC_SECRET:'synthetic env canary'});
try {await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});await callBrowser(f.evidence,'stop');}
finally {for(const n of names)if(saved[n]===undefined)delete process.env[n];else process.env[n]=saved[n];}
const result=JSON.parse(readFileSync(output));t.diagnostic(JSON.stringify(result));
assert.equal(result.file,'ENOENT');assert.equal(result.hostRoot,false);assert.equal(result.hostRuntime,false);
assert.equal(result.privateHome,true);assert.equal(result.privateRuntime,true);assert.equal(result.nss,true);
assert.ok(result.attempts.every(r=>r!=='connected'));assert.equal(pathnameHits,0);assert.equal(abstractHits,0);assert.equal(tcpHits,0);assert.equal(udpHits,0);
assert.equal(result.environment.PWD,'/home/browser');
assert.deepEqual(Object.keys(result.environment).sort(),['HOME','LANG','PATH','PWD','TMPDIR','XDG_CACHE_HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_RUNTIME_DIR'].sort());
});
after(async()=>{
const {createConnection}=await import('node:net');
for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{
const socket=createConnection({host:'127.0.0.1',port});
socket.once('connect',()=>{socket.destroy();reject(new Error(`test listener survived on ${port}`));});
socket.once('error',e=>e.code==='ECONNREFUSED'?resolve():reject(e));
});
});
+1 -1
View File
@@ -639,7 +639,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
+15 -6
View File
@@ -3,10 +3,12 @@
// does), the business file, and the boot message for the broker process.
// Everything here reads and refuses; nothing writes.
import { stackBinding } from "../../tasks/src/builtin.mjs";
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { managedState } from "../../business/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { busBusiness } from "../../bus/src/business.mjs";
import { BusError } from "../../bus/src/broker.mjs";
@@ -53,8 +55,11 @@ function business(fn) {
// entry comes from the one declared project whose file sets it. No
// tracker.baseUrl, or no project naming a tracker project, means no entry
// and no task verbs; two projects naming one each refuse, since the boot
// shape holds one tracker project per business.
function trackerFor(system, b, warn) {
// shape holds one tracker project per business. A product the request lifecycle is still building
// (Task 2, #797) holds the factory project file until its seed commit lands, so a reserved entry is
// skipped; a finished one is checked like any other and omits tracker.project, so the root board stays
// the one tracker project and no second adapter is made for a product.
function trackerFor(system, b, warn, dataRoot) {
if (!b.tracker) return null;
const first = Object.keys(b.roles)[0];
const base = resolveInstance({ system, business: b, instance: first }).vars;
@@ -62,6 +67,7 @@ function trackerFor(system, b, warn) {
const named = [];
for (const [id, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const project = loadProject(entry.root);
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
@@ -76,13 +82,13 @@ function trackerFor(system, b, warn) {
return null;
}
const v = named[0].vars;
return {
return { tracker: {
baseUrl: v["tracker.baseUrl"],
project: v["tracker.project"],
pollSeconds: v["tracker.pollSeconds"],
reconcileMinutes: v["tracker.reconcileMinutes"],
descendants: v["tracker.descendants"],
};
}, builtinBinding: stackBinding({ business: b.id, project: named[0].id, root: b.projects[named[0].id].root, tracker_project: v["tracker.project"] }) };
}
// FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file
@@ -136,8 +142,11 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
launches: [],
readers: [b.id],
};
const tracker = trackerFor(vars, b, warn);
if (tracker) config.trackers = { [b.id]: tracker };
const tracker = trackerFor(vars, b, warn, system.dataRoot);
if (tracker) {
config.trackers = { [b.id]: tracker.tracker };
if (tracker.builtinBinding) config.builtinBinding = tracker.builtinBinding;
}
const access = accessFor(system, env, warn);
if (access) config.access = access;
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
+1 -1
View File
@@ -324,7 +324,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`);
// The runner waits for "send"; bytes before it break the protocol.
if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send");
await op({ op: "checkPush", cap, task_ref });
await op({ op: "checkPush", cap, task_ref, project: req.project });
socket.write(`${JSON.stringify({ ok: true, send: size })}\n`);
await readBody(socket, file, size, deadlineOf(size), bodyIdle);
const result = await push({
+112 -5
View File
@@ -291,6 +291,7 @@ export function createLauncher({
const refusedRelease = new Map();
let refusedHolders = null;
let refusedRequests = null;
const requestDrains = new Map();
const sockets = new Set();
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
@@ -510,11 +511,21 @@ export function createLauncher({
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy };
}
// Reread host state on every new-run path, including opens/clears queued before archive.
async function threadAdmission(thread, session) {
if (!thread) return;
const rows = await host.op({ op: "threadsState", business: b.id });
const row = rows.find(r => r.thread === thread && r.session === session);
if (!row) throw new Refusal("session-not-found");
if (row.archived) throw new Refusal("project-archived");
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) {
admission(admissionGeneration);
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
await threadAdmission(thread, session);
// A thread session's store is checked first, so a bad id or an unsafe
// directory launches nothing.
let store = null;
@@ -564,11 +575,24 @@ export function createLauncher({
async function startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock }) {
// What the session sees: a legacy session's workspace at the old instance path.
const inSandbox = workspaceDest ?? workspace;
// Lifecycle Task 2 (#797): a product the request lifecycle built under <dataRoot>/products is cloned only
// once the bus has admitted it. The bus answers from its receipts, so a reserved or half-built product
// (a mapping published before the seed and records finished) is left out and the launch goes on without
// it. Ordinary mappings also recheck archive state through the same host admission.
const codeProjects = {};
for (const [project, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
let answer = null;
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
codeProjects[project] = entry;
}
await threadAdmission(thread, session);
let checkouts;
try {
checkouts = await withProcessing(dataRoot, () => {
admission(admissionGeneration);
return ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
return ensureCheckouts({ workspace, projects: codeProjects, env });
});
} catch (e) {
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
@@ -681,6 +705,16 @@ export function createLauncher({
readOnly,
};
admission(admissionGeneration);
await threadAdmission(thread, session);
// Archive can change while the checkout/bundle is prepared. Never spawn with a stale manifest.
for (const project of Object.keys(codeProjects)) {
const ready = await host.op({ op: "request.admission", business: b.id, project });
if (ready?.ready !== true) throw new Refusal(ready?.code ?? "project-pending");
}
// One synchronous broker snapshot covers the thread and the entire manifest after every
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
try {
@@ -781,12 +815,57 @@ export function createLauncher({
await threadPass();
await landPass();
}
await requestPass(halted);
await lifecyclePass();
} finally {
ticking = false;
}
}
// Request recovery starts only the configured chief through the existing --pm path.
// Workers always come back through that chief's ordinary launch tool/capability.
async function requestPass(halted = false) {
for (const [run, request] of launches && !halted ? requestDrains : []) {
const session = registry.sessions.find(s => s.run === run);
if (!session) { requestDrains.delete(run); continue; }
try {
const out = await host.op({ op: 'request.release', cap: request.cap, record: { business: b.id, run, instance: session.instance, project: request.project } });
if (out?.released) { terminate([session]); requestDrains.delete(run); }
} catch (e) {
// A revoked chief capability cannot keep retiring workers.
requestDrains.delete(run);
await host.op({ op: 'request.launchResult', record: { business: b.id, instance: session.instance, code: e.code ?? 'launch-failed', run: null } });
}
}
const pending = await host.op({ op: 'request.launches', business: b.id });
if (!Array.isArray(pending) || !pending.length) return;
if (!launches || halted) {
await host.op({ op: 'request.launchResult', record: { business: b.id, instance: b.launch.by, code: !launches ? 'launches-disabled' : 'processing-halted', run: null } });
return;
}
for (const i of pending) {
const session = registry.running(b.id, i.target, null);
if (!session || !i.project || stopping.has(session.run)) continue;
let projects = [];
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
if (!projects.includes(i.project)) {
const ready = await host.op({ op: 'request.admission', business: b.id, project: i.project });
if (ready?.ready === true) await host.op({ op: 'request.manifest', record: { business: b.id, instance: i.target, project: i.project, run: session.run } });
}
}
const chief = b.launch?.by;
if (!chief || registry.running(b.id, chief, null) || pending.some(i => i.chief_run)) return;
const next = pending.find(i => i.chief === chief && i.chief_attempt !== i.prerequisite);
if (!next) return;
try {
await host.op({ op: 'request.chief', business: b.id });
const out = await launchPm();
await host.op({ op: 'request.launchResult', record: { business: b.id, instance: chief, code: null, run: out.run } });
} catch (e) {
await host.op({ op: 'request.launchResult', record: { business: b.id, instance: chief, code: e.code ?? 'launch-failed', run: null } });
}
}
async function threadPass() {
let threads;
try {
@@ -1222,6 +1301,34 @@ export function createLauncher({
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
return { ok: false, error: "launches-disabled" };
}
const reply = async out => {
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
return out;
};
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
catch { return reply({ ok: false, error: 'organization-refused' }); }
// Product enums and clones are frozen in a session's manifest. A legitimate chief
// launch may request a drain, never interrupt the turn or start a privileged worker.
const running = registry.running(b.id, instance, null);
if (running && who.role === b.launch?.by) {
const pending = await host.op({ op: 'request.launches', business: b.id });
const needed = pending.filter(i => i.target === instance && i.project);
let projects = [];
try { projects = JSON.parse(readFileSync(join(running.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
const missing = needed.find(i => !projects.includes(i.project));
if (missing) {
if (!b.launch?.instances.includes(instance)) return reply({ ok: false, error: 'instance-not-listed' });
try {
await host.op({ op: 'authorizeLaunch', cap, instance });
const ready = await host.op({ op: 'request.admission', business: b.id, project: missing.project });
if (ready?.ready !== true) return reply({ ok: false, error: ready?.code ?? 'project-pending' });
const out = await host.op({ op: 'request.release', cap, record: { business: b.id, run: running.run, instance, project: missing.project } });
if (out?.released) terminate([running]);
else requestDrains.set(running.run, { cap, project: missing.project });
return reply({ ok: false, error: 'request-manifest-draining' });
} catch (e) { return reply({ ok: false, error: e.code ?? 'launch-failed' }); }
}
}
let checked;
try {
if (typeof instance !== "string") throw new Refusal("invalid-request");
@@ -1230,22 +1337,22 @@ export function createLauncher({
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: e.code };
return reply({ ok: false, error: e.code });
}
try {
await host.op({ op: "authorizeLaunch", cap, instance });
} catch (e) {
record({ event: "refused", by: who.role, instance, code: e.code ?? "not-authorized" });
return { ok: false, error: e.code ?? "not-authorized" };
return reply({ ok: false, error: e.code ?? "not-authorized" });
}
try {
return { ok: true, result: await start({ instance, launchedBy: who.role, ...checked }) };
return reply({ ok: true, result: await start({ instance, launchedBy: who.role, ...checked }) });
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: code };
return reply({ ok: false, error: code });
}
});
}
+28 -6
View File
@@ -12,17 +12,23 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--business <id>] [--json] [--tester]',
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]',
'mosaic project unarchive <name-or-id> [--business <id>]',
'A thread project named for a business repository must carry that repo: --repo <name>.',
'list --archived includes archived projects, each marked; archive is reversible and deletes nothing.',
].join('\n ');
export async function projectBusCommand(args, io, callFor) {
const [sub, ...rest] = args;
const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create'
? { flags: ['--tester'], values: ['--repo', '--business'] }
: { flags: ['--json', '--tester'], values: ['--business'] });
if (parsed.positional.length !== (sub === 'create' ? 1 : 0))
throw new CliError(`project ${sub} takes ${sub === 'create' ? 'one name' : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
: sub === 'list'
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
: { flags: [], values: ['--business'] });
const one = sub === 'create' || sub === 'archive' || sub === 'unarchive';
if (parsed.positional.length !== (one ? 1 : 0))
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
const call = callFor(parsed);
if (sub === 'create') {
const [name] = parsed.positional;
@@ -36,9 +42,25 @@ export async function projectBusCommand(args, io, callFor) {
}
return io.stdout.write(`created project ${name} id ${made.id}\n`);
}
const projects = await call('project.list');
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
// so an archived project can be named for unarchive.
if (sub === 'archive' || sub === 'unarchive') {
const [ref] = parsed.positional;
const projects = await call('project.list', { includeArchived: true });
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}; see mosaic project list`, 2);
try {
await call(`project.${sub}`, { project: hit.id });
} catch (error) {
if (error.code === 'project-already-archived') throw new CliError(`project ${hit.name} is already archived`, 2);
if (error.code === 'project-not-archived') throw new CliError(`project ${hit.name} is not archived`, 2);
throw error;
}
return io.stdout.write(`${sub === 'archive' ? 'archived' : 'unarchived'} project ${hit.name} id ${shortId(hit.id)}\n`);
}
const projects = await call('project.list', parsed.flags.has('--archived') ? { includeArchived: true } : {});
if (parsed.flags.has('--json')) return io.stdout.write(`${JSON.stringify(projects, null, 2)}\n`);
io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join(''));
io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name}${p.archived ? ' archived' : ''} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join(''));
}
// Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more