Compare commits

..
19 changed files with 540 additions and 291 deletions

No files matched your search

-70
View File
@@ -1,70 +0,0 @@
# Row214 C2-11a work record
Assignment sage-2794 at2026-10-22T16:33:20Z, Q220/mos-1301, issue#1715.
New isolated branch herb/row214 on aca16404c. The earlier197/215 worktrees
and their dirty/untracked historical packets are untouched. node_modules is
the required plain symlink to /mnt/storage/src/mosaic-stack/node_modules.
Read C2-11a in both pinned briefs. The section is unchanged at51cc0445c;
Q222 changes C2-06's reply-limit settings, not wikilinks. The editor model,
entry, view, existing model/browser/provenance tests, build/toolchain and
vendored markdown parser/serializer were read. No third_party source changes.
## Static finding and authored work
model.mjs stores a canonical sig and the raw original block, and serialise
returns raw when the sig matches. The vendored text writer escapes brackets
(to_markdown.ts:433-437). It is therefore possible for no-edit round-trips
to pass while dirty-block output escapes a wikilink. This is a source-based
hypothesis, not a measured result. Both outcomes are in DESIGN.md.
Authored, unrun: the pinned-bundle measurement; ten fixture round-trips;
three browser regressions; inert wikilink schema/tokenizer/serializer. The
schema includes headings, and front matter remains raw. The serializer
validates its spelling before bypassing escaping. HTML/entity-like targets
stay text under the existing escaping rules. No clicking or resolution.
Local static git diff --check is clean. No Node, npm, compiler, browser,
measurement, test, Docker, HTTPS Git or push has run. The queue claim waits
for a gap because scripts/mosaic invokes Node. No candidate or pass claim.
Only design/path documents may be committed before the granted red/green
checks; product, tests and measurement remain uncommitted until validated.
## Next granted steps
1. Claim from canonical during a CLEAR/gap.
2. Run measure.mjs against aca16404c's pinned Git blobs; save JSON unchanged.
3. Run the structural/edit regression alone on the baseline bundle (red).
4. Install the already-pinned esbuild0.28.2 in this worktree's packages/webui
with npm ci --ignore-scripts --no-audit --no-fund; no root install or lock edit.
5. Build with the existing editor/build.mjs, then the whole new test file and
existing editor, editor-browser and editor-provenance files, each singly.
6. Kill the serializer-removal mutant, restore, rebuild, focused green.
7. Commit only after the applicable checks, push only when Sage clears HTTPS
Git, send Filbert and Mos the candidate SHA/hash manifest directly, cc Sage.
8. After253 lands, new branch on its tip preserving all landed changes.
9. Filbert and Sage's whole webui and scripts gate finish the row.
All runs wait for explicit Sage CLEAR. Gander's row253 run5 owns the slot.
The whole suite gate and any unexpected failure use mos-0823's stop rule.
Proposed SESSIONS text for Sage's post-253 doc sweep (do not append here):
2026-10-22 | Herb (gpt-6.1-sol, f6cf4287) | row214 C2-11a editor wikilinks authoring | On herb/row214 at aca16404c, authored baseline round-trip measurement, designs for equal/unequal results, inert inline node and regression tests under sage-2794/Q220. Claim, runs and pushes held while253 owns the slot; measurement and test results unavailable.
## Filbert static design/path review accepted, R1 and R2 required
Review at2026-10-22T16:43:35Z read dee9f3d8d's packet only, no product or
runs. R1 is now in the plan: set MOSAIC_REQUIRE_EDITOR_BUILD=1 on focused
provenance and whole webui runs, grep ^# skip plus TAP/spec skip directives,
and name every skip. Missing/wrong pinned esbuild must fail, not skip.
R2: baseline no-edit roundTrips are expected to pass. They test retention
of raw bytes and are not red evidence for the node. Only the baseline
same-block structural/edit regression is expected red.
Two added fixtures cover a reference-definition collision and an atom
inside ordinary link text, taking the planned file to10 no-edit tests and
3 browser tests. The explicit & exclusion preserves the existing escaped
HTML/entity writer for dirty blocks; Q&A stays text and is a known limit.
Measurement VM setup matches loadEditor. Browser TMPDIR must use the short
non-/tmp scratch root. Sweep Node/Chromium after mutant/timeout and at clear.
All remain authored and unrun while253 owns the slot.
-128
View File
@@ -1,128 +0,0 @@
# C2-11a: an inert editor wikilink node
Base: aca16404c. Owner: Herb. Reviewer: Filbert. Issue: #1715.
Authority: sage-2794, Q220/mos-1301. The assignment permits authoring the
node and tests before the measurement slot. Runs, claim and pushes wait
for Sage's CLEAR. After row253 lands, a new branch will carry the change
onto its landing with every landed hunk retained.
## Measurement before choosing the explanation
Run agents/herb/work/queue-214/measure.mjs only on a granted slot. It loads
aca16404c's editor.js and markdown-preview.js directly from local Git
objects. It verifies the editor bundle hash against that base's BUILD.json.
It cannot accidentally measure a candidate rebuild. Its JSON records the
full baseline SHA, bundle hash, timestamp, input, output and equality for
each fixture. The first five are the brief's plain, labelled, heading,
list and front-matter cases; extra cases cover a heading block, combined
heading/label, Unicode/spacing, adjacency and an inert embed. No result is
available yet. Static reasoning is not a measurement.
Outcome A, every baseline round-trip is equal: record that wikilinks do
not trigger Source fallback for the measured inputs. Do not claim that
bug exists. The source's raw/sig cache explains why an unchanged block can
keep its bytes even when its canonical serializer escapes brackets. Add
the node for structured wikilinks and dirty-block preservation, as the
brief requests. A baseline structural/edit regression must fail even if
all unchanged-file checks pass.
Outcome B, one or more differ: record the exact differing bytes and affected
contexts. Keep the existing Source fallback fail-closed. The node restores
the required spellings where the block is structured. Retain the same
no-edit and dirty-block regressions. If the mismatch comes from a separate
block, line-ending or front-matter defect, report it before widening scope.
## Representation and conversion
The schema gains one inline atom, wikilink, with a single source attribute.
Its source is the complete spelling, not reconstructed target/label parts.
It has no href, navigation, fetch or click behavior. A span with data-source
and a text child displays the spelling. Heading content includes the new
atom; paragraphs, quotes and lists already accept inline content.
One markdown-it inline rule runs before ordinary links, after escapes and
backticks. A sticky match reads a complete single-line [[...]] or ![[...]]
without copying every suffix or crossing state.posMax. The node writer
writes its validated spelling without bracket escaping, so editing adjacent
text cannot rewrite a wikilink. Front matter remains a raw block. No new
file-read, save, routing, documentPath or target-resolution behavior is added.
The accepted atom grammar excludes brackets within the body, line breaks,
angle brackets and ampersands. This is an editor representation boundary,
not a library resolver or a refusal to open such a file. Malformed,
HTML/entity-like and code syntax stays under the existing text/raw rules.
This preserves the existing escaping of HTML/entities when a block changes.
DOM paste accepts only that same grammar. Invalid attributes constructed
programmatically are flattened to one line and escaped by the text writer.
No input string is parsed as HTML and embeds never load an image.
## Acceptance and review
The measurement JSON goes in BUILD.md and the final manifest packet.
Ten fixture round-trips run with and without a final newline. A browser
test inspects real ProseMirror atom types and attributes, edits the same
block and reparses it, for each fixture. Two more browser tests cover
code/escape/malformed/hostile syntax and DOM/programmatic-attribute safety.
Tests use the existing fixture and Chromium harness and the shipped bundle.
They do not export new production APIs just for testing.
Before rebuilding, run only the structural regression against the baseline
bundle and observe it fail. Rebuild the candidate with the exact pinned
esbuild0.28.2 binary, run the whole new test file, existing editor tests,
editor browser tests and provenance test. Remove only the wikilink serializer
handler for one mutant; the dirty-block assertion must fail. Restore, rebuild
and rerun the focused regression. All execution needs Sage's CLEAR, including
installation, checks, builds, browser tests and the measurement.
The checked-in bundle and editor/BUILD.json must be regenerated by the
existing build script. No hand edits of generated JavaScript or provenance.
The current shared esbuild is0.28.1 and the local webui install is absent.
A granted npm ci --ignore-scripts --no-audit --no-fund in packages/webui is
needed before building; the root node_modules remains its plain symlink.
No dependency or lockfile change is planned.
Final gate: Filbert approves the manifest, then the whole webui suite and
every scripts/test-*.sh on Sage's slot. Two failures stop a gate. A failure
outside this row is reported to Sage before any rerun. Source, generated
output, mode/hash manifest and raw granted logs identify the candidate.
## Static review follow-through (Filbert, 2026-10-22T16:43:35Z)
R1: the focused editor-provenance run and Sage's whole webui gate must set
MOSAIC_REQUIRE_EDITOR_BUILD=1. A missing, wrong-version or unpinned binary
then fails. Check every log for ^# skip, as well as TAP # SKIP directives
and the spec reporter's skipped-test lines; record each skipped name and
reason. A skipped rebuild is not proof of bundle reproducibility. The CSS
output must stay byte-identical and match BUILD.json.
R2: no-edit roundTrip checks are expected to pass on the baseline because
parse sets sig=canonical and an unchanged serialise returns lead+raw. Their
green result is compatibility evidence, never evidence that the atom was
needed or installed. The same-block edit/structural regression is the red
that proves the new node. The required measurement still runs and is kept.
N1: bus.js checks visual mode at949 on this base and also requires
MosaicMarkdown. The measurement uses the existing helper's bare-window VM
shape, markdown-preview.js first, then editor.js, both from pinned Git blobs.
N2: the exclusion of & and angle brackets is deliberate: the existing
text serializer escapes entities/HTML when it rewrites a block for another
renderer. An atom's verbatim writer must not bypass that rule, including
for pasted/programmatic values. This keeps those targets as ordinary text;
[[Q&A]] will still be escaped if its own block changes. It is a known
representation limit beyond the five brief cases, not a resolver change.
It is included among the text-only cases. Unedited bytes remain intact.
N3: the reference-definition fixture [[a]] with [a]: u deliberately becomes
one inert wikilink rather than a bracket around a reference link. Literal
spelling and the separate definition block remain intact.
N4: [x [[a]]](u) is an explicit fixture. The atom inherits the enclosing
ordinary link mark, whose existing target gate still applies. The atom
itself supplies no href or new click behavior; the fixture checks its
structure and spelling before/after a same-block edit.
N6: every granted browser/test process gets TMPDIR from
mktemp -d /mnt/storage/scratch/tmp/gXXX. Never /tmp. Sweep owned Chromium
and Node processes by cwd and the temp-root path after the mutant, after
any timeout, and before clearing the slot. Retain/report uncertain owners.
-23
View File
@@ -1,23 +0,0 @@
# Row214 implementation manifest, draft before measurement
Pinned base: aca16404c. Brief: docs/plans/2026-10-20_case2-rows.md,
C2-11a. Also read at gosalyn/case2-06-idea-caps51cc0445c; that section is
unchanged. Q222's reply-limit settings belong to C2-06, not this row.
| Path | Change |
| --- | --- |
| packages/webui/editor/model.mjs | inert inline atom, heading content, tokenizer rule and serializer |
| packages/webui/tests/editor-wikilink.test.mjs | ten round-trips and three browser regressions |
| packages/webui/tests/helpers/wikilink-cases.mjs | exact fixtures shared with measurement |
| packages/webui/src/public/editor.js | generated by the pinned editor build, pending CLEAR |
| packages/webui/editor/BUILD.json | generated provenance, pending CLEAR |
| agents/herb/work/queue-214/measure.mjs | baseline Git-object measurement, authored and unrun |
| agents/herb/work/queue-214/DESIGN.md | both measurement outcomes, implementation and acceptance |
| agents/herb/work/queue-214/BUILD.md | rulings and receipts, pending runs |
| agents/herb/work/queue-214/MANIFEST.md | this reviewed path list |
editor.css is an unchanged build output; no source CSS is touched. No
entry/view, library, route, save, click, documentPath or dependency changes.
Generated changes do not exist yet. This is a design/path manifest, not a
frozen tested candidate. Candidate hashes and measurement results follow
only after the granted runs. Filbert reviews the plan before the build.
+120
View File
@@ -0,0 +1,120 @@
# Row 239 (#1745) build notes (append only)
## 2026-10-22 b1 (Honker)
Design d1 5da5f28f6 was approved by Duckworth with C1 and C2 and no blockers (duckworth-0165, note
agents/duckworth/work/row239/REVIEW-design-d1.md). The code is on honker/row239 from aca16404c. No test
has run yet: every run waits for Sage's CLEAR.
### Duckworth's conditions and answers
- C1 (option a): `outcome-unknown-revoked` is in the Console's `OPEN` list (webui bus.mjs), so
`sendOutcome` answers it as `outcome-unknown` with `sent: 'unknown'` and never "Nothing changed". T6
covers it and M3f is its mutant.
- C2: `write()` sets `revoked = true` after `beforeWrite()` returns, and a catch around the whole
`withLock(...)` call marks the error. So a throw from the lock's release in its finally is marked as
well. This is structural and has no test, as Duckworth said.
- Q1: `beforeWrite` runs after both validations. Q2: two codes. Q3: M2 removes the `:188` check (now
organization.mjs:195).
### Notes N1-N5
- N1: `beforeWrite` must not write the same business, because it would find its own lock and refuse
`organization-busy`. This is said in the comments on `write()` and `commit()`. Today's revokes (the
bus's credential revoke) write no business file.
- N2: the lock is held across the revoke. A fire or a delete now keeps other writers of that business
out for as long as the revoke takes. They refuse `organization-busy`, as they do for any held lock.
- N3: a revoke that fails partway (some tokens revoked, then `credential-revoke-failed`) behaves as
today. The error carries no `revoked` mark, because `beforeWrite` didn't return, and nothing is
written. This row doesn't change that.
- N4: a delete closed with `outcome-unknown-revoked` exits 3 in the CLI, as every closed refusal does.
Exit 1 (transport EXIT) applies only to a direct verb answer.
- N5: I grepped for "before the write" and similar comments. The ones left (lifecycle-revoke.test:2,
instance-settings:56, cli instances.mjs:53, serve.mjs:365) are still true and are unchanged. T21 in
bus lifecycle-revoke.test:173 is unchanged.
### Differences from the design
- The business tests are in lifecycle-settings.test.mjs, not instance-settings.test.mjs. The fire tests
and their fixture already live there. The fixture now returns `dir` and `rolesDir` too.
- T3 covers T3b (the delete) in the same test, looping over fire and delete. It also checks that a
revoke throwing `credential-revoke-failed` carries no mark and writes nothing.
- T4b is the plain-code check. The design's "T4b, outcome-unknown-revoked for an fs-style error" is in
T4, which checks EIO with and without the mark for both a fire and a delete.
- The bus test's fake org gets `deleteError`, thrown from its `instances.set` branch, so T4 can drive a
delete.
- transport.test gets one assertion, `busExit("outcome-unknown-revoked") === 1`, for M3g.
- A latent Console text: a direct (not closed-request) `revision-conflict-revoked` answer in the
Console would still get " Nothing changed." appended, because only `OPEN` codes skip it. That sentence
is true there: the file is unchanged. No direct human path gives this code today, since a delete
reaches the Console as a closed request's refusal.
- The Console's STATUS entry for `outcome-unknown-revoked` (503) can't be reached while the code is in
`OPEN`. It stays for symmetry with the CLI and is harmless.
### What changed
- packages/business/src/organization.mjs: `write()` takes `beforeWrite`, runs it under the lock after
the revision check and both validations, and marks any later error `revoked`. The body is reindented
inside the try.
- packages/business/src/instance-settings.mjs: `instanceSettings` takes an optional `writer`. `commit()`
passes `beforeWrite` to `writer.write` instead of calling it. The early check stays, and its comment
is rewritten.
- packages/bus/src/broker.mjs: `#organizationFail` maps a marked error to `revision-conflict-revoked` or
`outcome-unknown-revoked` before the existing mappings. `#fireJob`'s notice adds "Its credential
tokens may already be revoked." for a `-revoked` code.
- packages/bus/src/runtime.mjs: comment only.
- packages/cli/src/instances.mjs: two REASONS entries. packages/cli/src/transport.mjs: EXIT
`outcome-unknown-revoked` 1.
- packages/webui/src/serve.mjs: two AGENT_REASONS entries. packages/webui/src/bus.mjs: `OPEN` entry and
two STATUS entries.
- Tests:
- business lifecycle-settings.test: "row 239 T1", "row 239 T2", "row 239 T3".
- bus fire-revokes-grants.test: "row 239 T4", "row 239 T4b".
- cli instances.test: "row 239 T5 CLI". cli transport.test: one assertion in "busExit and
refuseInsideAgent".
- webui agents.test: "row 239 T6".
- bus channel-proof.test re-pins packages/cli/src/transport.mjs (662642a8...) and
packages/webui/src/bus.mjs (1eba9774...), with a comment on each. Neither changes a proof or the
transport. The design's file list missed this. The pin grep found it before the first run.
### Mutants for the gate (exact-once replacements)
- M1 (instance-settings.mjs, beforeWrite before the lock): replace
` const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });` with
` if (beforeWrite) beforeWrite();\n const receipt = writer.write(business, next, { revision, actor, context });`.
The kill is lifecycle-settings "row 239 T1" (the revoke spy has a call).
- M2 (organization.mjs, the fire refusal dropped): delete the line
` if (revision !== before) fail("revision-conflict", "organization revision changed; read again");`.
The kill is "row 239 T1" (the write lands over the hand edit).
- M3a (organization.mjs, the mark dropped): ` if (revoked && e instanceof Error) e.revoked = true;`
becomes ` void revoked;`. The kills are "row 239 T2" and "row 239 T3" (business). T4 drives the mark
through a fake org, so it doesn't see M3a.
- M3b (broker.mjs): delete the line
` if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");`.
The kill is fire-revokes-grants "row 239 T4".
- M3c (cli instances.mjs): delete the five lines from ` "revision-conflict-revoked": "the business file`
through ` "business file again with \`mosaic business instances\` before trying again",`. The kill is
instances.test "row 239 T5 CLI".
- M3d (webui serve.mjs): delete the two lines starting ` 'revision-conflict-revoked': 'the business file`
and ` 'outcome-unknown-revoked': 'the agent\'s tokens`. The kill is agents.test "row 239 T6".
- M3e (broker.mjs): ` const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';`
becomes ` const tokens = '';`. The kill is "row 239 T4".
- M3f (webui bus.mjs, C1): the `OPEN` line loses `, 'outcome-unknown-revoked'`. The kill is "row 239 T6"
(the launch POST answers 503 outcome-unknown, sent unknown).
- M3g (cli transport.mjs): delete the line ` "outcome-unknown-revoked": 1,`. The kill is transport.test
"busExit and refuseInsideAgent".
A kill counts only as a failed assertion in the named test, not a load error or a different test.
### Gate plan (mos-1275 timeouts)
- My row 256 gate (2026-10-22 10:34Z, same machine) took 590s for the cli package, 57s for webui and
38s for test-foundation, the longest shell script.
- Packages: `timeout -k 10 1800` per package (3x the cli run).
- Each scripts/test-*.sh: `timeout -k 10 300` (more than 3x the 38s longest).
- Single files and mutants: `timeout -k 10 600` per file run (cli instances.test whole is the longest
single file).
- Order on Sage's CLEAR: the touched test files whole (business lifecycle-settings, bus
fire-revokes-grants and channel-proof, cli instances and transport, webui agents), then M1-M3g, then
the full gate.
- Before the in-review move: grep packages/*/tests for the changed comment and error text and for
64-hex pins on the changed files, and drift-check BASE..refactor against them.
+159
View File
@@ -0,0 +1,159 @@
# Row 239 design: the revoke under the writer's lock, and a fire test
Brief: docs/plans/2026-10-20_agent-lifecycle.md, "Brief: row 194 follow-up, the revoke under the writer's
lock and a fire test" (blob 3b3b3b2b9), from Duckworth's row 194 b3 N1 and mos-1158. Base aca16404c (row
194 landed as fd6dd327c, row 281 as e479521d3). Builder Honker, reviewer Duckworth, gate owner Sage.
Draft 1, 2026-10-22.
## Today
- `instance-settings.mjs` `commit()` runs the checks, then compares the caller's revision with a fresh
`writer.read` (row 194 b3, sage-1634), then calls `beforeWrite` (the fire's or the delete's credential
revoke), then `writer.write`.
- `writer.write` (`organization.mjs`) takes the lock, runs `recover`, checks the revision again (`:188`),
validates, prepares, checks the bytes once more before publication (`:221`), renames and commits.
- So two windows are left.
- A write that lands between `commit()`'s read and the writer's lock still gets a revoke and then a
`revision-conflict`.
- Any refusal after the revoke ran (the `:221` check, or an I/O failure the bus reports as
`outcome-unknown`) uses the same text as one before it, so nothing says the tokens may be gone.
- Row 281 left this path alone. Its fire job (`broker.mjs` `#fireJob`) runs step G (grants), then step B,
which is this same `write('instance.fire')`, and reports a step B failure as `fire.failed` along with a
human notice that names the code. Its delete job only does step G. The delete itself still commits
through `lifecycleDelete` → `instances.set` op delete → `commit()`.
## What changes
### 1. `beforeWrite` runs inside `writer.write`, under the lock (organization.mjs)
`write(id, document, { revision, actor, detail, context, beforeWrite = null })`. The order under the lock:
1. `recover`, read, then the `:188` revision check. A refusal here revokes nothing.
2. Projects-protected and both validations. A refusal here revokes nothing either. I put `beforeWrite`
after these rather than straight after `:188`, so a document the writer would refuse anyway never
costs a revoke. Q1 asks Duckworth to confirm this.
3. `beforeWrite()`. If it throws (today `credential-revoke-failed`), the error propagates as it is and
nothing has been written, as now.
4. Prepare, the `:221` check, rename and commit, wrapped so that any error thrown after step 3 gets
`revoked = true` set on it before it is rethrown. That covers `revision-conflict` from `:221`, an
`organization-refused` from `log()`, a barrier throw, and an fs error.
`record()` and the other writers (`launch-settings`, `plugin-settings`, `credentialVerbs`) pass no
`beforeWrite` and behave as before.
### 2. commit() passes beforeWrite through (instance-settings.mjs)
`commit()` passes `beforeWrite` to `writer.write` and no longer calls it itself. The row 194 b3 early check
stays. It costs one read, keeps T21's refusal before any validation work, and is no longer what protects
the revoke. Its comment is rewritten to say so, and the "(BUILD.md follow-up)" sentence goes.
`instanceSettings({ ..., writer = organizationWriter({ dir, rolesDir }) })` takes an optional writer, so a
test can inject one (the brief's "injected writer"). `runtime.mjs` passes none.
### 3. The bus names a refusal after a revoke (broker.mjs `#organizationFail`)
The bus answers with a code only, and the CLI and Console look the text up by code. So the "may already be
revoked" signal has to be a code. Two new codes:
- `revision-conflict-revoked`: `e.revoked && e.code === 'revision-conflict'`.
- `outcome-unknown-revoked`: `e.revoked` with any other code. The write may or may not have landed, since a
failure after the rename is recovered as committed.
Both are checked before the existing mappings. An error without `revoked` maps exactly as today, so a
refusal before any revoke keeps today's code and text. Q2 asks about the names.
### 4. The texts
- CLI `instances.mjs` REASONS:
- `revision-conflict-revoked`: "the business file changed before the write, after the agent's tokens
were revoked; the tokens may already be revoked and the instance can't use them; read the instances
again and run the same command again, which is safe".
- `outcome-unknown-revoked`: "the write's outcome is unknown and the agent's tokens may already be
revoked; read the instances before trying again".
- Console `serve.mjs` AGENT_REASONS: the same two, in the Console's wording ("delete again").
- `#fireJob`'s `fire.failed` notice: when the code ends in `-revoked`, it adds "Its credential tokens may
already be revoked." The chief's synchronous fire (no grants) answers with the code, as every chief verb
does.
- `transport.mjs` EXIT: `outcome-unknown-revoked` exits 1, as `outcome-unknown` does. Console
`bus.mjs` STATUS: `revision-conflict-revoked` 409, `outcome-unknown-revoked` 503. A delete's code
reaches both as a closed request's `refusal`, so these two only matter for a direct verb answer.
## Tests
- **T1, the brief's fire test** (business `instance-settings.test.mjs`). The fire uses an injected writer
that wraps the real one. Its `write` makes one hand edit of the business file (a vars change on another
instance) and then calls the real `write`, so the edit lands after `commit()`'s early check and before
the lock's `:188` check. Expected:
- the fire refuses `revision-conflict` without `revoked`;
- the revoke spy has no calls;
- the business file's bytes are the hand edit's;
- changes.jsonl has no new prepared entry.
- **T2** (same file). A fire on a real writer built with `barrier: stage => stage === 'prepared' && edit()`.
The revoke runs, then `:221` refuses. Expected: `revision-conflict` with `revoked === true`, one revoke
call, the file's bytes are the edit's, and the last receipt is `aborted`.
- **T3** (same file). The same, with a `prepared` barrier that throws. Expected: the error has
`revoked === true`, one revoke call, and the file unchanged. A delete through the same writer gives the
same result (T3b).
- **T4** (bus `fire-revokes-grants.test.mjs`, its injected `f.org`). `f.org.fireError` is a
`revision-conflict` with `revoked: true`. Expected:
- the job's `fire.failed` carries `revision-conflict-revoked`, and the inbox message says "may already
be revoked";
- the same with no `grants` (the synchronous fire) refuses `revision-conflict-revoked`;
- a plain `revision-conflict` still maps to `revision-conflict` and the message lacks the sentence.
- T4b: `outcome-unknown-revoked` for an fs-style error with `revoked`.
- **T5** (cli `lifecycle.test.mjs` or `instances.test.mjs`, whichever already reads a closed delete's
refusal). A delete closed with `revision-conflict-revoked` prints the new text, and one closed with
`revision-conflict` prints today's.
- **T6** (webui `agents.test.mjs`). The same pair for the Console's reason.
- T21 (bus `lifecycle-revoke.test.mjs`) stays as it is, for delete.
## Mutants
| Mutant | Change | Killed by |
| --- | --- | --- |
| M1 | `beforeWrite` before the lock: `commit()` calls `beforeWrite()` itself and passes none to `write` | T1 (the revoke spy has a call) |
| M2 | The fire refusal dropped: the `:188` revision check removed | T1 (the write lands over the hand edit) |
| M3a | The mark dropped: no `revoked = true` in the catch | T2, T3, and T4 through the codes |
| M3b | `#organizationFail` ignores `revoked` | T4 |
| M3c | CLI text: the two REASONS entries removed | T5 |
| M3d | Console text: the two AGENT_REASONS entries removed | T6 |
| M3e | The notice: the `fire.failed` sentence removed | T4 |
M3a-M3e together are the brief's "drop the may already be revoked text".
## Files
- `packages/business/src/organization.mjs`
- `packages/business/src/instance-settings.mjs`
- `packages/business/tests/instance-settings.test.mjs`
- `packages/bus/src/broker.mjs`
- `packages/bus/tests/fire-revokes-grants.test.mjs`
- `packages/cli/src/instances.mjs`
- `packages/cli/src/transport.mjs`
- one cli test file
- `packages/webui/src/serve.mjs`
- `packages/webui/src/bus.mjs`
- `packages/webui/tests/agents.test.mjs`
- This packet.
## Gate
As the brief says:
- Duckworth approves the manifest.
- The bus, business, cli and webui suites and every `scripts/test-*.sh` are green on Sage's slot, with
the touched test files run whole.
- M1-M3e are killed.
Before the in-review move I grep the tests for the changed comment and error text, and for hash pins on the
changed files.
## Questions for Duckworth
- **Q1.** Should `beforeWrite` run after the writer's validations (my pick) or straight after `:188` (the
brief's literal place)? Running it later means a document the writer would refuse revokes nothing.
- **Q2.** Are two new codes, `revision-conflict-revoked` and `outcome-unknown-revoked`, the right way to
carry the signal? The alternative is to give `BusError` a detail field, which every CLI and Console
reader would then have to learn. The codes are additive and no existing code changes meaning.
- **Q3.** The brief's mutant "drop the fire refusal": I read it as removing the `:188` check (M2). If you
meant the fire test's own refusal, say so and I'll redefine it.
+6 -1
View File
@@ -1947,11 +1947,13 @@ export class Broker {
} catch (e) {
const code = e instanceof BusError ? e.code : 'outcome-unknown';
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
// Row 239 (#1745): a step B refusal after the writer's revoke ran.
const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';
this.#store.transaction(() => {
this.#event(sc, 'action.allowed', { action: 'fire.failed', job, step, code }, name);
this.#humanNotice(
sc.business,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}). The instance is still firing. Run the fire again once ${code} is cleared.`,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
);
});
}
@@ -4405,6 +4407,9 @@ export class Broker {
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
// the human reads that the tokens may already be revoked. Without the mark, today's code.
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
+5 -5
View File
@@ -148,11 +148,11 @@ export async function startBroker({
const levels=organization?await harnessLevels():()=>null;
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
// file unchanged.
// after the writer's checks and before its write, under the writer's lock (row 239). Fire revokes
// each service of the entry and keeps the store directory, delete calls remove (sage-1593). A
// service the running bus doesn't know (an agent created since the start) has nothing in memory,
// so it goes on, and then no token or tmp file may remain in the agent's store directory
// (sage-1592). Any failure stops the verb with the file unchanged.
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
function revoke(business, agent, how, services) {
const instance = business + '/' + agent;
+5 -2
View File
@@ -17,7 +17,8 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 239 (#1745): EXIT outcome-unknown-revoked 1; no proof or transport change.
"packages/cli/src/transport.mjs": "662642a8ce739fcb6c188ad07125a94e008c8a305eced127c8041d472c56e55b",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
@@ -25,7 +26,9 @@ const baselines = {
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
// 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": "1eba977443b3e308dbc5b08567b11e1059bb238a5f57939c6917dd775156e5cf",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
@@ -133,8 +133,9 @@ async function setup(t) {
},
};
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
const org = { log: [], fireError: null };
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse, and
// `deleteError` the delete's (row 239).
const org = { log: [], fireError: null, deleteError: null };
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
let broker;
const organization = (business, op, args) => {
@@ -146,6 +147,7 @@ async function setup(t) {
return { revision: REV, transaction: 't-' + org.log.length };
}
if (op === 'instances.set') {
if (org.deleteError) throw org.deleteError;
delete doc.roles[args.instance];
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
broker.reloadBusiness('demo', doc);
@@ -952,3 +954,44 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
});
// Row 239 (#1745): the writer marks an error raised after its own revoke ran (revoked: true), and the
// bus names it, so the human reads that the tokens may already be revoked. Unmarked, today's code.
test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the job's fire.failed and notice, a synchronous fire and a delete", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict-revoked). Its credential tokens may already be revoked. The instance is still firing.'), message);
// helper has no grant, so its fire writes synchronously and answers with the code.
await rejects(() => f.fire('helper'), 'revision-conflict-revoked');
// Any other marked error: the write may have landed.
f.org.fireError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
await rejects(() => f.fire('helper'), 'outcome-unknown-revoked');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
await rejects(() => f.fire('helper'), 'revision-conflict');
f.org.fireError = null;
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
assert.equal(f.remove('helper').code, 'revision-conflict-revoked');
f.org.deleteError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
assert.equal(f.remove('helper').code, 'outcome-unknown-revoked');
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
assert.equal(f.remove('helper').code, 'revision-conflict');
});
test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice says nothing of the tokens", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
assert.ok(!message.includes('may already be revoked'), message);
});
+9 -10
View File
@@ -144,9 +144,8 @@ function createdCredentials(old, instance, definition, args) {
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
const writer = organizationWriter({ dir, rolesDir });
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -379,8 +378,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// The checks every write runs, then beforeWrite (a fire's or a delete's revoke), then the write. A
// check that refuses revokes nothing, and a revoke that throws writes nothing.
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
@@ -410,12 +410,11 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. The revision is checked here, before the revoke, because
// the writer's own check comes after it. A write between this read and the writer's lock still
// revokes and then refuses (BUILD.md follow-up).
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
if (beforeWrite) beforeWrite();
const receipt = writer.write(business, next, { revision, actor, context });
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
+62 -47
View File
@@ -177,55 +177,70 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
function write(id, document, { revision, actor, detail, context }) {
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// 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 }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
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');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// 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');
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
let revoked = false;
try {
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
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');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// 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');
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
@@ -4,9 +4,10 @@
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { OrganizationError, organizationWriter } from "../src/organization.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
@@ -36,7 +37,7 @@ function fixture(mutate) {
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const revision = () => settings.read("acme").revision;
return { root, file, settings, validate, log, onDisk, revision };
return { root, dir, rolesDir, file, settings, validate, log, onDisk, revision };
}
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
@@ -244,3 +245,73 @@ test("row 256 checkDelete passes the human's creative delete and refuses another
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
// Row 239 (#1745): a fire's or a delete's revoke runs inside the writer, under its lock, after the
// revision check. revoking(f, writer, fail) builds settings on f's files with a spy revoke and that writer.
function revoking(f, writer, fail = null) {
const calls = [];
const revoke = (business, agent, how, services) => {
calls.push([business, agent, how, services]);
if (fail) throw fail;
};
const settings = instanceSettings({ dir: f.dir, rolesDir: f.rolesDir, system: systemFor(f.root), revoke, ...(writer ? { writer } : {}) });
return { settings, calls };
}
const receipts = f => (existsSync(f.settings.writer.logFile("acme")) ? f.log() : []);
// A hand edit: the same document in other bytes, so the revision changes and the file still validates.
const handEdit = f => {
const bytes = JSON.stringify(f.onDisk()) + "\n";
writeFileSync(f.file, bytes);
return bytes;
};
test("row 239 T1 a fire on a revision that goes stale before the writer's lock refuses there and revokes nothing", () => {
const f = fixture();
const real = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir });
let edited = null;
// The edit lands after commit()'s early check and before the writer takes its lock.
const writer = { ...real, write: (id, document, options) => { edited = handEdit(f); return real.write(id, document, options); } };
const { settings, calls } = revoking(f, writer);
const before = receipts(f).length;
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === undefined);
assert.deepEqual(calls, []);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).length, before);
});
test("row 239 T2 a write after the revoke refused before publication carries revoked, and the file keeps the edit", () => {
const f = fixture();
let edited = null;
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") edited = handEdit(f); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === true);
assert.deepEqual(calls, [["acme", "sec-1", "fire", []]]);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).at(-1).state, "aborted");
});
test("row 239 T3 any error after the revoke carries revoked, for a fire and a delete; a revoke that throws carries nothing", () => {
for (const [how, act] of [
["fire", settings => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT })],
["delete", (settings, f) => settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "delete" }, "jason")],
]) {
const f = fixture();
const bytes = readFileSync(f.file, "utf8");
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") throw new Error("stopped at prepared"); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => act(settings, f), e => e.message === "stopped at prepared" && e.revoked === true, how);
assert.equal(calls.length, 1, how);
assert.equal(calls[0][2], how);
assert.equal(readFileSync(f.file, "utf8"), bytes, how);
const g = fixture();
const gBytes = readFileSync(g.file, "utf8");
const failing = revoking(g, null, new OrganizationError("credential-revoke-failed"));
assert.throws(() => act(failing.settings, g), e => e.code === "credential-revoke-failed" && e.revoked === undefined, how);
assert.equal(failing.calls.length, 1, how);
assert.equal(readFileSync(g.file, "utf8"), gBytes, how);
assert.deepEqual(receipts(g), [], how);
}
});
+6
View File
@@ -55,6 +55,12 @@ export const REASONS = {
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
"command again, which is safe",
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
"revision-conflict-revoked": "the business file changed after the agent's tokens were revoked and before the write, so " +
"the file is unchanged; the tokens may already be revoked and the instance can't use them; read it again with " +
"`mosaic business instances` and run the same command again, which is safe",
"outcome-unknown-revoked": "the agent's tokens may already be revoked and the write's outcome is unknown; read the " +
"business file again with `mosaic business instances` before trying again",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
+1
View File
@@ -42,6 +42,7 @@ const EXIT = {
"tester-off": 3,
"tester-refused": 3,
"outcome-unknown": 1,
"outcome-unknown-revoked": 1,
"response-too-large": 1,
"invalid-response": 1,
};
+22
View File
@@ -316,6 +316,28 @@ test("row 194 CLI V5: delete polls to the transaction and the read lists the nam
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
test("row 239 T5 CLI: a delete closed -revoked says the tokens may already be revoked; a plain revision-conflict doesn't", async (t) => {
let refusal = "revision-conflict-revoked";
const s = await setup(t, { hook: closing(() => ({ refusal })) });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
const del = async () => s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
let r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict-revoked: the business file changed after the agent's tokens were revoked .*; the tokens may already be revoked .*which is safe \(request [^)]+\)$/m);
refusal = "outcome-unknown-revoked";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: outcome-unknown-revoked: the agent's tokens may already be revoked and the write's outcome is unknown; .* \(request [^)]+\)$/m);
refusal = "revision-conflict";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict: the business file changed since that revision; .* \(request [^)]+\)$/m);
assert.doesNotMatch(r.err, /may already be revoked/);
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
+2
View File
@@ -45,6 +45,8 @@ test("a bus code on stderr becomes the exit code; garbage and timeouts are outco
test("busExit and refuseInsideAgent", () => {
assert.equal(busExit("unauthenticated"), 3);
assert.equal(busExit("invalid-request"), 2);
// Row 239 (#1745): a write whose outcome is unknown after its revoke exits as outcome-unknown does.
assert.equal(busExit("outcome-unknown-revoked"), 1);
assert.doesNotThrow(() => refuseInsideAgent(humanEnv()));
assert.throws(() => refuseInsideAgent({ PI_AGENT_DIR: "/x" }), (e) => e.exitCode === 3 && /PI_AGENT_DIR/.test(e.message));
});
+4 -1
View File
@@ -75,7 +75,8 @@ export const busReader = options => views({ call: busCaller(options) });
// A send's error, sorted. The broker writes in one transaction, so any code it
// returns means nothing was written. A transport that lost the answer, or an
// answer that can't be read, leaves the outcome open: the message may exist.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
// Row 239 (duckworth-0165 C1): so does a write that failed after its revoke.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
export function sendOutcome(err) {
const code = err instanceof BusReadError ? err.code : 'outcome-unknown';
return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false };
@@ -87,6 +88,8 @@ const STATUS = {
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
+3
View File
@@ -364,6 +364,9 @@ const AGENT_REASONS = {
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
// Brief item 8, deviation 11 (sage-1620): the revoke runs before the write, so a retry is safe.
'credential-revoke-failed': 'a credential couldn\'t be revoked, so the business file is unchanged. Tokens already revoked stay revoked and the instance can\'t use them. Clear what else the agent\'s store directory holds and delete again, which is safe',
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
'revision-conflict-revoked': 'the business file changed after the agent\'s tokens were revoked and before the write, so the file is unchanged. The tokens may already be revoked and the instance can\'t use them. Read the role instances again and delete again, which is safe',
'outcome-unknown-revoked': 'the agent\'s tokens may already be revoked and the write\'s outcome is unknown. Read the role instances again before trying',
'capacity-full': 'a launch limit is reached',
'limit-reached': 'a launch limit is reached',
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
+18
View File
@@ -220,3 +220,21 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
// duckworth-0165 C1: a direct answer of outcome-unknown-revoked is an open outcome, never "Nothing changed".
test('row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome', async t => {
let refusal = 'revision-conflict-revoked';
const s = await setup(t, { agent: async (verb, args) => { if (verb !== 'instance.request') throw new BusReadError('outcome-unknown-revoked'); return { ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal } }; } });
const reason = async () => (await get(s, `/api/agent/request?id=${ID}`)).body.reason;
assert.match(await reason(), /business file changed after the agent's tokens were revoked .*\. The tokens may already be revoked .* delete again, which is safe$/);
refusal = 'outcome-unknown-revoked';
assert.match(await reason(), /tokens may already be revoked and the write's outcome is unknown\. Read the role instances again before trying$/);
refusal = 'revision-conflict';
assert.match(await reason(), /the business file changed since you read it/);
assert.doesNotMatch(await reason(), /may already be revoked/);
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
assert.doesNotMatch(unknown.body.message, /Nothing changed/);
});