feat(ledger): Piece E, queue section in the weekly ledger (row 13, #1508)
The ledger prints a queue section above the weekly table. It checks four things: - open issues named by done rows; - owner registrations for active rows; - closed issues for done rows; - the age of required rows. The result is fail, incomplete or reduced pass. It uses its own Gitea budget of the open list plus at most 10 lookups. A full open page counts only while an issue in some row's closes has no known state (lead decision 40). T3 seats are exempt per run with --unsupported-runtime. The weekly routine is in packages/ledger/README.md. Built by Darkwing (build.patch ab1f12ca, manifest 0b20bbca). Filbert reviewed it: round 1 81f26f2e asked for changes (C1, ISO requiredSince never aged); round 2 ce8ce150 approved. Also carries Filbert's plan amendment for decision 40 (68a25ffe). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,285 @@
|
|||||||
|
# Queue E (#1508, row 13), candidate for review, round 2
|
||||||
|
|
||||||
|
Darkwing, 2026-09-27. Piece E is the ledger's queue section, per plan
|
||||||
|
section 8.10 (`agents/filbert/work/queue-as-data-plan-2026-09-26.md`) and
|
||||||
|
the brief's "Piece E: ledger checks the queue". The ledger now checks
|
||||||
|
`docs/plans/queue.json` against Gitea and the seat registrations and prints
|
||||||
|
the result above the weekly table. Filbert reviews E. Sage commits. Nothing
|
||||||
|
is committed, staged or pushed.
|
||||||
|
|
||||||
|
Round 2 answers Filbert's round 1 review
|
||||||
|
(`agents/filbert/work/queue-e-review-r1-2026-09-27.md`, sha256 81f26f2e…):
|
||||||
|
C1, plus n1, n2 and n3. See "Round 2" below. The round 1 files are kept
|
||||||
|
unchanged in `r1/` (patch 02a01c29…, manifest cad51929…, build.md
|
||||||
|
d3bdb826…).
|
||||||
|
|
||||||
|
Round 1 was amended before its verdict for lead decision 40 (origin
|
||||||
|
2333d837): a full open-issue page is undecided only while some issue a row
|
||||||
|
closes has no known state. See "The full page" below.
|
||||||
|
|
||||||
|
Base is f3f48cfd. Nothing under `packages/ledger`, `packages/queue/src` or
|
||||||
|
`packages/seat/src` changed between it and origin 2333d837. In a fresh
|
||||||
|
clone at 2333d837 the patch applies, the result matches the manifest 5/5,
|
||||||
|
and `node --test packages/ledger/tests/` passes 78/78 in three runs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
`build.patch` (sha256
|
||||||
|
`ab1f12cad711284f8a722ea51fa73cd8e344c703701f8b76957ae33de091ae84`) changes 3
|
||||||
|
files and adds 2. `build-manifest.sha256` (sha256
|
||||||
|
`0b20bbca6c9e0a00d39dc7aae0ca2e268b6908c4b24e9f9b401df8268d29c5fe`) pins all 5
|
||||||
|
after the patch.
|
||||||
|
|
||||||
|
- `packages/ledger/src/queue-checks.mjs` (new): reads the queue, classifies
|
||||||
|
owners, makes the issue calls, runs the checks, lists protected changes,
|
||||||
|
formats the section.
|
||||||
|
- `packages/ledger/src/cli.mjs`: `--no-queue`, `--unsupported-runtime SEAT`
|
||||||
|
(repeatable), the queue read before any Gitea call, the section above the
|
||||||
|
table and under `queue` in `--json`.
|
||||||
|
- `packages/ledger/README.md`: a "Queue section" with the rules, the call
|
||||||
|
budget, the result levels and the weekly routine. The heading "One Gitea
|
||||||
|
call" becomes "Gitea calls", and the exit-code paragraph names the queue.
|
||||||
|
- `packages/ledger/tests/queue-checks.test.mjs` (new, 20 tests).
|
||||||
|
- `packages/ledger/tests/ledger.test.mjs`: the fixture copies
|
||||||
|
`packages/queue/src` and `packages/seat/src`, which the ledger now
|
||||||
|
imports, and its runs pass `--no-queue`. Those tests cover the weekly
|
||||||
|
table; the new file covers the queue section. 58 tests before, 78 now.
|
||||||
|
|
||||||
|
`docs/TOOLS.md` has no ledger entry today, so there is no TOOLS patch. The
|
||||||
|
README is the reference, as it was for Piece 3.
|
||||||
|
|
||||||
|
## What a run prints
|
||||||
|
|
||||||
|
```
|
||||||
|
Queue checks: queue.json revision 19, as of 2026-09-27T16:09:10.883Z
|
||||||
|
owner darkwing (row 13): exempt, declared with --unsupported-runtime
|
||||||
|
...
|
||||||
|
liveness: 0 pid-present (unverified), 3 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone
|
||||||
|
declared unsupported runtime: darkwing, dewey, sage
|
||||||
|
queue issue checks: open list (full page), 2 lookups
|
||||||
|
protected changes in range: 0 (not checks; confirm the actors)
|
||||||
|
queue: 0 violations; result reduced pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Each finding is a line `violation|undecided|disposition <check> <row> <#issue>:
|
||||||
|
<message>`, so its identity (check, row, issue) can be read off the line for
|
||||||
|
the same-day remediation run. The JSON carries the same objects.
|
||||||
|
|
||||||
|
## Choices and where they differ from the plan
|
||||||
|
|
||||||
|
1. **A new module.** The plan's file list puts `queueChecks` in
|
||||||
|
`ledger.mjs`. I put the section in `queue-checks.mjs`, as `t3.mjs` did
|
||||||
|
for the T3 source, so `ledger.mjs` stays the metric code. The CLI
|
||||||
|
wires it in.
|
||||||
|
2. **Registrations through `readRegistration`**, not the control board's
|
||||||
|
scan. It returns a record, null or a thrown error per seat, with no text
|
||||||
|
to parse, and it keeps the board's import chain out of the ledger.
|
||||||
|
Liveness is its own signal-0 probe; EPERM counts as present.
|
||||||
|
3. **Closure uses `closes`, not `issues`** (J6). Rows 9 to 12 name #1508
|
||||||
|
but close nothing, so they can be done while it is open. The brief's
|
||||||
|
literal "a row naming an open issue" would flag them.
|
||||||
|
4. **A malformed or absent pid is `invalid`, not `pid-unknown`.** The plan's
|
||||||
|
table puts it under `pid-unknown`. `validateRegistration` rejects a pid
|
||||||
|
that is not a positive integer or null, so the record fails validation
|
||||||
|
first. `invalid` is a violation where `pid-unknown` is undecided, so the
|
||||||
|
difference fails closed. `pid-unknown` is a valid record with a null pid.
|
||||||
|
5. **A registration for another checkout is `missing`.** The plan defines
|
||||||
|
missing as no registration for (canonical root, `repo`, seat). A `repo`
|
||||||
|
record whose `sessionsDir` is not under the queue's canonical root
|
||||||
|
matches the seat name but not the root.
|
||||||
|
6. **An unreadable config makes every non-exempt owner `invalid`**, and
|
||||||
|
the report still runs. It does not refuse, because the other checks don't
|
||||||
|
need the data root.
|
||||||
|
7. **Refusals.** A missing, symlinked or hand-edited `queue.json` exits 1
|
||||||
|
before any Gitea call; the message names `--no-queue`. A failed or
|
||||||
|
malformed open-list call exits 2, like the metric call, and names
|
||||||
|
`--no-issues`. A failed lookup, a 404, a pull request or a number that
|
||||||
|
doesn't match leaves only that issue unknown.
|
||||||
|
8. **On by default.** `--no-queue` skips the section and prints `Queue: not
|
||||||
|
checked (--no-queue)`. It can't be combined with `--unsupported-runtime`.
|
||||||
|
`--unsupported-runtime` is the only repeatable flag; a repeated seat is
|
||||||
|
refused.
|
||||||
|
9. **Age is as of the run**, not `--until`: the gate is about today's queue,
|
||||||
|
and the table's range doesn't move it. "More than 14 days" is whole days,
|
||||||
|
so a row required on 2026-09-13 turns on 2026-09-28.
|
||||||
|
10. **Exit 0 whenever a report was computed.** The result is in the text and
|
||||||
|
the JSON, like every other number the ledger prints.
|
||||||
|
11. **Protected changes (added).** The 8.10 checks don't include it, but R4
|
||||||
|
and R10 ("E lists every protected change from [the journal]"), J2 and
|
||||||
|
the queue README's trust boundary ("piece E lists changes for review")
|
||||||
|
do. So the section lists every log entry dated inside `--since`/`--until`
|
||||||
|
that changes a row which is required or parked before or after the
|
||||||
|
entry, with rev, verb, claimed actor and rows. It replays the log up to
|
||||||
|
the range with the queue's own `replay` and `applyEntry`. It is a list,
|
||||||
|
not a check: it never changes the result. For 2026-09-20 to 2026-09-26
|
||||||
|
it lists nothing, because genesis was 2026-09-27; for a range that
|
||||||
|
includes today it lists 18 entries at rev 19, on rows 8 to 13 and 26 to 30.
|
||||||
|
|
||||||
|
## Round 2
|
||||||
|
|
||||||
|
- **C1, an ISO `requiredSince` never aged.** `set required` and `add
|
||||||
|
--required` write an ISO time, and round 1 appended `T00:00:00Z` to it,
|
||||||
|
which parses to NaN. Now `Date.parse` reads the value as it is (a bare
|
||||||
|
date parses as 00:00Z), and the result is floored to its UTC day. One
|
||||||
|
deviation from the suggested fix: an ISO time counts from 00:00Z of its
|
||||||
|
day, not from its hour, so both forms age in whole UTC days and a Monday
|
||||||
|
run's result doesn't depend on the hour a row was made required. A row
|
||||||
|
required at 23:59Z on 2026-09-13 turns on 2026-09-28, as a date-only row
|
||||||
|
does. A value that doesn't parse is an `age-invalid` violation. I chose
|
||||||
|
a violation over undecided because row 13's gate counts violations, and
|
||||||
|
a bad value is a queue defect. The queue validator lets one through:
|
||||||
|
`ISO_RE` checks the shape, so `2026-13-01T00:00:00.000Z` passes it. That
|
||||||
|
gap is in `packages/queue/src/queue.mjs`, outside E; I'm raising it as a
|
||||||
|
follow-up, not fixing it here.
|
||||||
|
- **n1, the orphaned curl.** Each queue call now runs as `timeout -s KILL
|
||||||
|
60 gitea-api.sh GET ...`, as D's `callTool` does, so the kill takes the
|
||||||
|
helper's process group. A kill reads `no answer within 60 s`, and exit
|
||||||
|
126 or 127 from `timeout` (no helper) reads `gitea-api.sh unavailable`.
|
||||||
|
The metric call in `ledger.mjs` still uses execFileSync's timeout. It
|
||||||
|
isn't in this patch, so that is a follow-up too.
|
||||||
|
- **n2, a reopened issue.** Closed evidence from the metric page now needs
|
||||||
|
`state: "closed"` and a `closed_at`. Either one alone leads to a lookup.
|
||||||
|
- **n3.** The budget detail is `over the lookup budget`, so the message
|
||||||
|
reads `unknown (over the lookup budget)`.
|
||||||
|
|
||||||
|
Two tests are new. One covers an ISO `requiredSince` at 15 days (fails), at
|
||||||
|
14 (passes), at 23:59Z fifteen days back (fails), and one that doesn't
|
||||||
|
parse (`age-invalid`, result fail). The other gives the calls a hanging
|
||||||
|
helper that starts a hanging child and a 1-second deadline. Both calls
|
||||||
|
return at the deadline and neither process survives. A missing helper
|
||||||
|
reads `gitea-api.sh unavailable`. The metric fixture adds a reopened issue
|
||||||
|
and a `state: "closed"` entry with no `closed_at`, and both are looked up.
|
||||||
|
|
||||||
|
Round 2 mutants, all 12 killed:
|
||||||
|
|
||||||
|
1. The round 1 template restored.
|
||||||
|
2. No floor to the UTC day.
|
||||||
|
3. No NaN guard.
|
||||||
|
4. NaN as undecided.
|
||||||
|
5. Ceiling instead of floor.
|
||||||
|
6. The metric check on `closed_at` alone.
|
||||||
|
7. The metric check on `state` alone.
|
||||||
|
8. A Node timeout with SIGKILL in place of `timeout`, with ETIMEDOUT
|
||||||
|
ignored. The orphan assertion kills it.
|
||||||
|
9. The kill flag always false.
|
||||||
|
10. The kill flag read from the exit status alone.
|
||||||
|
11. No 126/127 mapping.
|
||||||
|
12. The old budget detail.
|
||||||
|
|
||||||
|
A first version kept the suggested `DATE_RE` branch, and dropping it was an
|
||||||
|
equivalent mutant because `Date.parse` already reads a bare date as 00:00Z.
|
||||||
|
I removed the branch.
|
||||||
|
|
||||||
|
## The full page (lead decision 40)
|
||||||
|
|
||||||
|
mosaicstack/stack has 50 or more open issues, so the open list is always a
|
||||||
|
full page. Plan 8.10 made a full page undecided on its own. But an issue
|
||||||
|
missing from the page is looked up, so the page matters only when an issue
|
||||||
|
is left without a known state, and that issue is already undecided. Sage
|
||||||
|
approved the change. Now `open-list-full` is added only when the page is
|
||||||
|
full and some issue a row closes is `unknown`, and its message names
|
||||||
|
those issues. The text still prints `open list (full page)`, and the JSON
|
||||||
|
keeps `openListFull`.
|
||||||
|
|
||||||
|
Tests cover a full page with every issue resolved (no undecided item), an
|
||||||
|
open issue off the page found by lookup (its done row fails), and a full
|
||||||
|
page with an issue past the budget (still incomplete). They drive the fake
|
||||||
|
helper end to end through `issueStates` and `queueChecks`.
|
||||||
|
|
||||||
|
## Live run (read-only)
|
||||||
|
|
||||||
|
Round 2, in a fresh clone at 2333d837 with the patch applied, through the
|
||||||
|
new `timeout` path: the same result as below with the exemptions, 0
|
||||||
|
violations, `reduced pass`, 2 lookups, exit 0.
|
||||||
|
|
||||||
|
Round 1, at 2333d837 in the verify clone, with my own token file in
|
||||||
|
`MOSAIC_GITEA_CREDENTIAL_FILE`, for 2026-09-20 to 2026-09-26: exit 0, the
|
||||||
|
metric call, the open list and 2 lookups. That is GET only, and nothing
|
||||||
|
was posted.
|
||||||
|
|
||||||
|
- Plain run: 3 violations, result `fail`. Rows 13 (darkwing) and 5 (dewey)
|
||||||
|
are `pid-gone`, from old pi launches; row 7 (sage) is `missing`. All
|
||||||
|
three seats run in T3, which writes no registration.
|
||||||
|
- With `--unsupported-runtime` for darkwing, dewey and sage: 0 violations,
|
||||||
|
result `reduced pass`. Before the amendment the same run was
|
||||||
|
`incomplete`, from the full page alone.
|
||||||
|
- No issue violations: every issue in a done row's `closes` is closed.
|
||||||
|
|
||||||
|
This is not the acceptance run. That is a dated run posted on #1508 after
|
||||||
|
approval.
|
||||||
|
|
||||||
|
## Lead decision 40
|
||||||
|
|
||||||
|
Sage ruled on the five points I raised:
|
||||||
|
|
||||||
|
1. The full page: approved as amended above.
|
||||||
|
2. Row 7's brief pins `packages/ledger/README.md` at blob 3a2ce27c. Sage
|
||||||
|
re-pins it with `set 7 brief` in a queue commit right after E lands.
|
||||||
|
3. Row 7 stays. Its gate is Jason's, so Q9 is amended. The weekly routine
|
||||||
|
stays in the README.
|
||||||
|
4. The age rule stays. From 2026-09-28 the Monday run lists rows 9, 10, 11
|
||||||
|
and 13, which is accurate.
|
||||||
|
5. The T3 exemption is accepted as built. The weekly run declares every T3
|
||||||
|
seat that owns an active row.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`packages/ledger/tests/queue-checks.test.mjs`. In-process checks run on
|
||||||
|
fixture rows with a fixed clock and an injected pid probe. CLI tests use a
|
||||||
|
scratch repository whose `queue.json` the real queue CLI wrote, a temporary
|
||||||
|
config and data root, and a fake `gitea-api.sh` that routes the open list,
|
||||||
|
single issues and the metric page and logs every call. No test reads a real
|
||||||
|
token, registration, config or `~/.t3`; HOME and MOSAIC_CONFIG are
|
||||||
|
temporary.
|
||||||
|
|
||||||
|
- Issues: done with the issue open (fail) or closed (pass); a multi-row
|
||||||
|
issue open while one closer is pending, closed early (disposition), and
|
||||||
|
open with both done; rows 9 to 12's shape (issues without closes);
|
||||||
|
unknown and not run (incomplete); a full page is undecided only beside an
|
||||||
|
unknown issue.
|
||||||
|
- Issue calls: open list first, metric page next, then at most 10 lookups
|
||||||
|
in order and `unknown (budget)` after; a pull request on the open list is
|
||||||
|
not an issue; lookups of a pull request, a mismatched number and a 404
|
||||||
|
are unknown; a metric entry with no `closed_at` is not closed evidence;
|
||||||
|
a full page. The open list refuses on exit 3, exit 1, bad JSON and bad or
|
||||||
|
closed records, and never echoes the helper's stderr.
|
||||||
|
- Owners: every class in one run, including another checkout and a broken
|
||||||
|
record; two rows for one owner; briefed and done owners not checked; no
|
||||||
|
config.
|
||||||
|
- Age: 15 days fails, 14 doesn't; done and not-required rows are skipped;
|
||||||
|
the legacy bound at 20 days (fail), exactly 14 and 3 (undecided); an ISO
|
||||||
|
`requiredSince` by UTC day, and one that doesn't parse (round 2).
|
||||||
|
- Deadline: a hanging helper and its child are both killed (round 2).
|
||||||
|
- `pidAlive`: running, exited, and EPERM (pid 1, non-root).
|
||||||
|
- `readQueue`: a real queue, a hand edit, a missing file, a symlink.
|
||||||
|
- Protected changes: genesis, a note on a required row, a note on an
|
||||||
|
ordinary row (not listed), an unpark by jason (listed from the row
|
||||||
|
before), and range boundaries (start included, end excluded).
|
||||||
|
- CLI: section above the table; `--json` key; a clean queue prints `queue:
|
||||||
|
0 violations; result reduced pass`; call counts (3 with issues, 0 with
|
||||||
|
`--no-issues`, 1 with `--no-queue`); refusals cost no call; flag errors.
|
||||||
|
|
||||||
|
Mutation testing, round 1: 37 hand-made mutants of `queue-checks.mjs` and the CLI
|
||||||
|
wiring (boundaries, each class, each result level, budget, filters,
|
||||||
|
refusals, the protected-change range and guard, the full-page rule). All
|
||||||
|
37 are killed. The
|
||||||
|
first pass left three alive (the 14-day genesis edge, EPERM, a null
|
||||||
|
`closed_at`) and a later one three more (the before-row guard and both
|
||||||
|
range edges); the tests above were added for them.
|
||||||
|
|
||||||
|
Round 2 adds the 12 listed above.
|
||||||
|
|
||||||
|
Suites: `node --test packages/ledger/tests/` 78/78, three runs;
|
||||||
|
`packages/queue/tests` and `packages/seat/tests` 161/161.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone -q /mnt/storage/src/mosaic-stack /tmp/e && cd /tmp/e
|
||||||
|
git checkout -q 2333d837
|
||||||
|
git apply /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build.patch
|
||||||
|
sha256sum -c /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build-manifest.sha256
|
||||||
|
ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules
|
||||||
|
node --test packages/ledger/tests/
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26 --no-t3 --no-issues
|
||||||
|
```
|
||||||
@@ -137,6 +137,10 @@ Changed:
|
|||||||
`packages/ledger`, so A leaves a one-line hand-written pointer to the
|
`packages/ledger`, so A leaves a one-line hand-written pointer to the
|
||||||
weekly routine below the markers. E moves the routine into
|
weekly routine below the markers. E moves the routine into
|
||||||
`packages/ledger/README.md` and removes the pointer.
|
`packages/ledger/README.md` and removes the pointer.
|
||||||
|
*Amended by lead decision 40 (2026-09-27):* row 7 stays in the queue
|
||||||
|
until Jason closes it, because its gate is his. E still writes the
|
||||||
|
weekly routine into the ledger README. A left no pointer below the
|
||||||
|
markers, so E has none to remove.
|
||||||
- `docs/TOOLS.md`: usage lines. This file carries other owners' uncommitted
|
- `docs/TOOLS.md`: usage lines. This file carries other owners' uncommitted
|
||||||
changes; add a scoped patch the way #1511 did.
|
changes; add a scoped patch the way #1511 did.
|
||||||
|
|
||||||
@@ -167,6 +171,8 @@ Changed:
|
|||||||
that was row 7).
|
that was row 7).
|
||||||
- One more Gitea call, `state=open`, one page; a full page fails as today
|
- One more Gitea call, `state=open`, one page; a full page fails as today
|
||||||
(Q7). Referenced issues absent from that list count as closed.
|
(Q7). Referenced issues absent from that list count as closed.
|
||||||
|
*Superseded by 8.10:* absent issues are looked up, and a full page is
|
||||||
|
handled as lead decision 40 rules.
|
||||||
- Reads `docs/plans/queue.json` through the queue validator by relative
|
- Reads `docs/plans/queue.json` through the queue validator by relative
|
||||||
import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare
|
import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare
|
||||||
`@mosaic/queue` import would not resolve (R14).
|
`@mosaic/queue` import would not resolve (R14).
|
||||||
@@ -371,6 +377,10 @@ items have no owner or issue.
|
|||||||
*Decided:* row 7 moves to `packages/ledger/README.md` as the weekly
|
*Decided:* row 7 moves to `packages/ledger/README.md` as the weekly
|
||||||
routine (sequenced across A and E; see 2.A). The parked items become rows
|
routine (sequenced across A and E; see 2.A). The parked items become rows
|
||||||
with state `parked`, owner `unassigned` and issue null.
|
with state `parked`, owner `unassigned` and issue null.
|
||||||
|
*Amended by lead decision 40 (2026-09-27):* row 7 stays until Jason
|
||||||
|
closes it. Its gate is Jason's, and closing a Jason-gated row needs his
|
||||||
|
cited approval, so the lead can't retire it alone. E writes the weekly
|
||||||
|
routine into `packages/ledger/README.md` anyway.
|
||||||
|
|
||||||
## 5. Where the brief contradicts the code or itself
|
## 5. Where the brief contradicts the code or itself
|
||||||
|
|
||||||
@@ -1741,7 +1751,7 @@ source is unchanged.
|
|||||||
| Calls | Purpose |
|
| Calls | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1 | Metrics, unchanged. |
|
| 1 | Metrics, unchanged. |
|
||||||
| 1 | Queue checks: `state=open`, limit 50. A full page makes the queue issue checks "incomplete". |
|
| 1 | Queue checks: `state=open`, limit 50. A full page makes the queue issue checks "incomplete" only when some issue in a row's `closes` is left without a known state (lead decision 40). |
|
||||||
| up to 10 | Queue checks: `GET issues/N` for issues in some row's `closes` that are neither in the open list nor shown closed in the metric page. Issues beyond 10 are "unknown (budget)", and the run is incomplete. |
|
| up to 10 | Queue checks: `GET issues/N` for issues in some row's `closes` that are neither in the open list nor shown closed in the metric page. Issues beyond 10 are "unknown (budget)", and the run is incomplete. |
|
||||||
|
|
||||||
That is at most 12 calls. `--no-issues` makes 0 calls and prints `queue
|
That is at most 12 calls. `--no-issues` makes 0 calls and prints `queue
|
||||||
@@ -1784,8 +1794,9 @@ days (legacy lower bound)". Until then its age is undecidable.
|
|||||||
`missing`, `invalid` or `pid-gone` owner, and any issue or age violation.
|
`missing`, `invalid` or `pid-gone` owner, and any issue or age violation.
|
||||||
- `incomplete`: no known violation, but something couldn't be decided. That
|
- `incomplete`: no known violation, but something couldn't be decided. That
|
||||||
covers:
|
covers:
|
||||||
- an issue check that was `unknown (budget)`, hit a full page, or was not
|
- an issue check that was `unknown (budget)`, or was not run;
|
||||||
run;
|
- a full open page while some issue in a row's `closes` has no known
|
||||||
|
state;
|
||||||
- a `pid-unknown` owner;
|
- a `pid-unknown` owner;
|
||||||
- an undecidable legacy age.
|
- an undecidable legacy age.
|
||||||
- `reduced pass`: nothing known and nothing undecided. The liveness evidence
|
- `reduced pass`: nothing known and nothing undecided. The liveness evidence
|
||||||
@@ -2411,3 +2422,10 @@ The round-2 modifications:
|
|||||||
with no reviewer approvals, so a Jason-gated row could close without its
|
with no reviewer approvals, so a Jason-gated row could close without its
|
||||||
reviewers. That move now carries the in-review→done checks on a comment
|
reviewers. That move now carries the in-review→done checks on a comment
|
||||||
round; the table and 8.9's receipts paragraph say so.
|
round; the table and 8.9's receipts paragraph say so.
|
||||||
|
- 2026-09-27: amended for lead decision 40 (Piece E questions). A full
|
||||||
|
`state=open` page makes the queue issue checks incomplete only when some
|
||||||
|
issue in a row's `closes` is left without a known state. An issue off
|
||||||
|
the page is looked up or left `unknown (budget)`, so truncation can't
|
||||||
|
hide a violation. 8.10's budget table and result list say so. Row 7
|
||||||
|
stays in the queue until Jason closes it; Q9 and 2.A say so. Section 2's
|
||||||
|
E text points to 8.10.
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Queue Piece E review, round 1 (#1508, row 13)
|
||||||
|
|
||||||
|
Filbert, 2026-09-27. Plan: `queue-as-data-plan-2026-09-26.md` §8.10,
|
||||||
|
amended for lead decision 40 (plan sha256 68a25ffe…, uncommitted).
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Changes requested, one item (C1).** The review covers the amended
|
||||||
|
round-1 candidate: `build.patch` sha256
|
||||||
|
02a01c291890626f58ba103d1c65e853b042b609887868bf59dea7f9b50b227d at
|
||||||
|
2333d837, with `build-manifest.sha256` cad51929… and `build.md` d3bdb826….
|
||||||
|
I had also reviewed the first version (patch 78445322…, manifest
|
||||||
|
8f0e4fea…) in full. The amendment changes only the full-page rule, its
|
||||||
|
tests and the README, so everything I checked on the first version still
|
||||||
|
holds.
|
||||||
|
|
||||||
|
C1 is a real bug in the age check, and it's small. Everything else is
|
||||||
|
ready, including the decision-40 change.
|
||||||
|
|
||||||
|
## C1. The age check never flags a row made required after genesis
|
||||||
|
|
||||||
|
**Where:** `packages/ledger/src/queue-checks.mjs`, line 200 in the amended
|
||||||
|
file:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const days = ageDays(Date.parse(`${r.requiredSince}T00:00:00Z`), now);
|
||||||
|
if (days > AGE_LIMIT_DAYS) add(violations, 'age', ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens.** The code assumes `requiredSince` is a date. Genesis
|
||||||
|
rows carry dates (rows 9, 10, 11 and 13 have `2026-09-13`). But the queue
|
||||||
|
writes a full ISO time for any row made required later:
|
||||||
|
`set <id> required true` sets `requiredSince = entry.at`
|
||||||
|
(`packages/queue/src/queue.mjs:860`), and `add --required` does the same
|
||||||
|
(line 904). The validator accepts both forms (line 322, `checkTime(…,
|
||||||
|
{ date: true })`). For an ISO time the template gives
|
||||||
|
`2026-09-01T12:00:00.000ZT00:00:00Z`, `Date.parse` returns NaN, `days`
|
||||||
|
is NaN, and `NaN > 14` is false. The row is never an age violation, and
|
||||||
|
nothing is reported as undecided either.
|
||||||
|
|
||||||
|
**Reproduced** in-process at 2026-09-27T12:00Z on one required,
|
||||||
|
in-progress row:
|
||||||
|
- `requiredSince: "2026-09-01"` → violations `owner-invalid, age`, result
|
||||||
|
`fail`;
|
||||||
|
- `requiredSince: "2026-09-01T12:00:00.000Z"` → violations `owner-invalid`
|
||||||
|
only. The age violation is missing.
|
||||||
|
|
||||||
|
(`owner-invalid` comes from passing no seats; it doesn't matter here.)
|
||||||
|
|
||||||
|
**Why it matters.** Today's rows all have dates, so Monday's run is right.
|
||||||
|
The first row made required through the queue CLI would slip past the
|
||||||
|
14-day gate without a word. A check that goes quiet is the failure the
|
||||||
|
ledger's result levels exist to prevent.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
- A `DATE_RE` value parses as `T00:00:00Z`; anything else goes to
|
||||||
|
`Date.parse` as it stands.
|
||||||
|
- If the result is NaN, fail closed: an `age` undecided item naming the
|
||||||
|
row, or a violation. Never silence. (The validator should make this
|
||||||
|
unreachable, but the check shouldn't depend on that.)
|
||||||
|
- Tests: an ISO `requiredSince` 15 days old fails and one 14 days old
|
||||||
|
doesn't, next to the existing date cases. Your fixtures build
|
||||||
|
`requiredSince` with `since(n)`, so an ISO variant fits in the same test.
|
||||||
|
- Mutant: restore the old template. The new test should kill it.
|
||||||
|
- The README's age paragraph can say that `requiredSince` is a date for
|
||||||
|
genesis rows and an ISO time for later ones.
|
||||||
|
|
||||||
|
## The full page (lead decision 40)
|
||||||
|
|
||||||
|
I agree with the ruling, and the amendment implements it correctly. An
|
||||||
|
issue is never treated as closed because it's missing from the open list.
|
||||||
|
It's looked up or left `unknown (budget)`, and the unknown state already
|
||||||
|
makes the run incomplete. A server that caps pages below 50 would make
|
||||||
|
`full` meaningless anyway, so dropping it as a standalone signal costs
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
What I checked on the delta:
|
||||||
|
- `open-list-full` is added only when the page is full and some issue in
|
||||||
|
`wanted` is `unknown`. `wanted` is the union of every row's `closes`,
|
||||||
|
including rows that aren't done. An unknown issue on a pending row can
|
||||||
|
only affect a disposition, but it still keeps the page undecided. That
|
||||||
|
errs toward `incomplete`, and it matches the decision's wording ("some
|
||||||
|
wanted issue"). Keep it.
|
||||||
|
- The new end-to-end test drives the fake helper through `issueStates` and
|
||||||
|
`queueChecks` for all three cases: resolved, open off the page, and past
|
||||||
|
the budget.
|
||||||
|
- The README's result list and call table match the code.
|
||||||
|
- My plan now says the same thing: 8.10's budget table and result list,
|
||||||
|
a pointer from section 2's E text, and Q9 and 2.A for row 7.
|
||||||
|
|
||||||
|
## What I checked
|
||||||
|
|
||||||
|
All of this ran in scratch clones with push disabled: `/tmp/fqe` at
|
||||||
|
f304eaa5 for the first version, and `/tmp/fqe2` at 2333d837 for the
|
||||||
|
amendment. The inputs were frozen as 0444 copies.
|
||||||
|
|
||||||
|
- **Manifest and suites.** The amended manifest checks 5/5. `node --test
|
||||||
|
packages/ledger/tests/` passes 76/76, and `packages/queue/tests` with
|
||||||
|
`packages/seat/tests` passes 161/161. The first version had passed
|
||||||
|
75/75 and 161/161.
|
||||||
|
- **Source.** I read `queue-checks.mjs` in full, and the diffs to
|
||||||
|
`cli.mjs`, the README and both test files. I compared the amendment with
|
||||||
|
the first version file by file. Only `queue-checks.mjs` (the rule and its
|
||||||
|
comment), the README (two passages) and `queue-checks.test.mjs` changed.
|
||||||
|
- **Local run** on the first version, for 2026-09-27 with `--no-issues
|
||||||
|
--no-t3` and three seats declared: 0 violations, result `incomplete`
|
||||||
|
(issues not run), 18 protected changes.
|
||||||
|
- **Mutations.** I wrote 21 mutants of my own against the first version
|
||||||
|
(E1–E21), apart from Darkwing's 37. The suite kills 19. The two
|
||||||
|
survivors are equivalent:
|
||||||
|
- E2 checks the metric page before the open list. Both are current
|
||||||
|
sources, so they can't disagree about an issue.
|
||||||
|
- E8 applies the age check to rows that aren't required. The validator
|
||||||
|
refuses `requiredSince` on such a row, so the mutant changes nothing.
|
||||||
|
|
||||||
|
I wrote five more against the amendment (F1–F5), and the suite kills
|
||||||
|
all five:
|
||||||
|
- F1: a full page is always undecided;
|
||||||
|
- F2: an unknown issue makes `open-list-full` without a full page;
|
||||||
|
- F3: any state other than open counts as unresolved;
|
||||||
|
- F4: only `unknown (budget)` counts, so a failed lookup doesn't;
|
||||||
|
- F5: it takes two unknown issues.
|
||||||
|
|
||||||
|
None of my 26 mutants touches the age parsing. The C1 bug is in an input
|
||||||
|
shape the fixtures don't use.
|
||||||
|
|
||||||
|
## Darkwing's choices
|
||||||
|
|
||||||
|
I agree with choices 1 to 11 in `build.md`. Two of them depart from the
|
||||||
|
plan's table, and in both cases the change fails closed:
|
||||||
|
- 4: a malformed pid is `invalid`, not `pid-unknown`, so it's a violation
|
||||||
|
rather than undecided;
|
||||||
|
- 5: a registration for another checkout is `missing`, because it matches
|
||||||
|
the seat name but not the root.
|
||||||
|
|
||||||
|
Choice 11 (protected changes listed, never checked) is what R4, R10 and J2
|
||||||
|
asked for.
|
||||||
|
|
||||||
|
## Non-blocking
|
||||||
|
|
||||||
|
- **n1. The call deadline kills the helper, not curl.** `execFileSync`
|
||||||
|
with a 60-second `timeout` sends SIGTERM to `gitea-api.sh` only. I
|
||||||
|
checked the behaviour with a script that runs a child `sleep`:
|
||||||
|
`execFileSync` throws `ETIMEDOUT` at the deadline, so the ledger's wall
|
||||||
|
time stays bounded, but the child keeps running as an orphan. For the
|
||||||
|
ledger that means a stuck curl can outlive the run, and the helper's
|
||||||
|
response file may be left in `/tmp`. It's a GET, so nothing is written
|
||||||
|
to Gitea. D wrapped its helper call in `timeout -s KILL` for this.
|
||||||
|
Doing the same here is a small change. It can go in this round or
|
||||||
|
later.
|
||||||
|
- **n2. `closedInMetric` could also check `state === 'closed'`.** It uses
|
||||||
|
`closed_at` alone. I haven't checked whether Gitea clears `closed_at`
|
||||||
|
when an issue is reopened. If it doesn't, a reopened issue that is off
|
||||||
|
the open page but on the metric page would count as closed. The open
|
||||||
|
page is always full now, so that case can happen. Checking `state` as
|
||||||
|
well settles it either way.
|
||||||
|
- **n3. The unknown-budget message doubles a word.** The test pins
|
||||||
|
`unknown (unknown (budget))`. It would read better as `unknown (budget)`
|
||||||
|
or `unknown (lookup budget)`. Cosmetic.
|
||||||
|
- **n4. `--unsupported-runtime` is self-declared.** The flag is a
|
||||||
|
statement from whoever runs the ledger, not evidence. It's printed on
|
||||||
|
every run, which is what 8.10 asks for. Nothing to change.
|
||||||
|
|
||||||
|
## For Darkwing and Sage
|
||||||
|
|
||||||
|
Fix C1 with its test and mutant, then send the patch and manifest again.
|
||||||
|
I'll review round 2 against C1 only, plus n1 if it's in. My plan
|
||||||
|
amendment (68a25ffe…) goes into E's commit with this review.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Queue Piece E review, round 2 (#1508, row 13)
|
||||||
|
|
||||||
|
Filbert, 2026-09-27. Round 1: `queue-e-review-r1-2026-09-27.md`
|
||||||
|
(sha256 81f26f2e…). This round checks C1, n1, n2 and n3.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approved.** The review covers `build.patch` sha256
|
||||||
|
ab1f12cad711284f8a722ea51fa73cd8e344c703701f8b76957ae33de091ae84 at
|
||||||
|
2333d837, with `build-manifest.sha256` 0b20bbca… and `build.md`
|
||||||
|
75571f0b…. C1 is fixed, and so are n1, n2 and n3. Two of my mutants
|
||||||
|
survive. Neither hides a defect; see the notes below. Nothing needs a
|
||||||
|
round 3.
|
||||||
|
|
||||||
|
## What I checked
|
||||||
|
|
||||||
|
All of this ran in a scratch clone, `/tmp/fqe3`, at 2333d837 with push
|
||||||
|
disabled, on frozen 0444 copies of the three inputs. Darkwing's `r1/`
|
||||||
|
copies still match the hashes I reviewed in round 1.
|
||||||
|
|
||||||
|
- **Manifest and suites.** The manifest checks 5/5. `node --test
|
||||||
|
packages/ledger/tests/` passes 78/78, and `packages/queue/tests` with
|
||||||
|
`packages/seat/tests` passes 161/161.
|
||||||
|
- **What changed.** I compared all five files with the round-1 candidate.
|
||||||
|
`cli.mjs` and `ledger.test.mjs` are unchanged. `queue-checks.mjs`, the
|
||||||
|
README and `queue-checks.test.mjs` change only for C1, n1, n2 and n3.
|
||||||
|
- **C1.**
|
||||||
|
- `requiredDay` floors `Date.parse` to 00:00Z of its UTC day. A bare date
|
||||||
|
parses as 00:00Z, so the separate date branch I suggested would add
|
||||||
|
nothing. Dropping it is right.
|
||||||
|
- Counting an ISO time from its UTC day rather than its hour is a change
|
||||||
|
from my fix, and I agree with it. Both forms age in whole UTC days. A
|
||||||
|
row can be flagged up to a day early, never late.
|
||||||
|
- A value that doesn't parse is an `age-invalid` violation, so the run
|
||||||
|
fails. Any finding in `violations` counts toward the result, and no
|
||||||
|
other code matches on the check name, so the new name needs no other
|
||||||
|
wiring.
|
||||||
|
- The tests cover an ISO time at 15 days (fails), at 14 days (passes),
|
||||||
|
and at 23:59Z fifteen days back (fails, which needs the floor), and
|
||||||
|
month 13 (`age-invalid`, result fail).
|
||||||
|
- **n1.** Each call runs as `timeout -s KILL 60 gitea-api.sh GET …`. Without
|
||||||
|
`--foreground`, GNU timeout signals the whole process group, which kills
|
||||||
|
curl too. The new test starts a helper with a hanging child and a 1 s
|
||||||
|
deadline, then checks that both pids are gone. Adding `--foreground`
|
||||||
|
leaves the child alive, and the test catches it (R7).
|
||||||
|
- **n2.** Metric-page evidence needs `state === 'closed'` and a
|
||||||
|
`closed_at`. The metric call returns the raw Gitea records, filtered but
|
||||||
|
not mapped (`ledger.mjs` `readIssues`), so `state` is there in real runs.
|
||||||
|
The fixture covers a reopened entry (`closed_at` only) and one with
|
||||||
|
`state` only. Both are looked up.
|
||||||
|
- **n3.** The message reads `is unknown (over the lookup budget)`.
|
||||||
|
- **Mutations.** I wrote 13 mutants of my own for this round. The suite
|
||||||
|
kills 11:
|
||||||
|
- R1: no floor on `requiredDay`;
|
||||||
|
- R2: the NaN guard removed;
|
||||||
|
- R3: `age-invalid` counted as undecided;
|
||||||
|
- R4: metric evidence on `closed_at` alone;
|
||||||
|
- R5: metric evidence on `state` alone;
|
||||||
|
- R6: the default TERM signal in place of KILL (caught by the message);
|
||||||
|
- R7: `--foreground` (caught by the orphan check);
|
||||||
|
- R8: a kill recognised only by exit 137;
|
||||||
|
- R10: exit 127 no longer read as "unavailable";
|
||||||
|
- R11: `ceil` in place of `floor`;
|
||||||
|
- R12: a signal-killed call treated as success.
|
||||||
|
|
||||||
|
Two survive:
|
||||||
|
- **R9**, a kill recognised only by `r.signal === 'SIGKILL'`. Without
|
||||||
|
`--foreground`, GNU timeout sends KILL to its own group and dies with
|
||||||
|
it, so `spawnSync` sees the signal, not exit 137. The `status === 137`
|
||||||
|
branch is defensive and can't be reached in this setup. The mutant is
|
||||||
|
equivalent.
|
||||||
|
- **R13**, `if (r.error) throw r.error;` removed. With `timeout` missing
|
||||||
|
from PATH, the call still fails, but the message says "credential or
|
||||||
|
Gitea request failure" rather than "the timeout command is
|
||||||
|
unavailable". That's still a refusal (exit 2); only the wording is
|
||||||
|
wrong. See n1.
|
||||||
|
|
||||||
|
## Non-blocking
|
||||||
|
|
||||||
|
- **n1. The missing-`timeout` message has no test (R13).** A test could
|
||||||
|
point `PATH` at an empty directory for one call and give the helper by
|
||||||
|
absolute path. That's optional. The failure is closed either way.
|
||||||
|
- **n2. A day past the end of the month doesn't parse as invalid.** V8
|
||||||
|
turns `2026-02-30` and `2026-02-30T00:00:00.000Z` into 2026-03-02. It
|
||||||
|
returns NaN only for values like month 13. So `age-invalid` catches some
|
||||||
|
impossible dates but not all. A row whose date overflows ages from the
|
||||||
|
wrong day and gets no warning. This belongs with Darkwing's follow-up
|
||||||
|
(a): the queue validator checks shape, not calendar. A calendar check
|
||||||
|
there, such as a round trip through `toISOString`, closes both. The CLI
|
||||||
|
never writes such a value, and readQueue refuses hand edits, so it can't
|
||||||
|
happen today.
|
||||||
|
- **Darkwing's follow-ups.** I agree with both:
|
||||||
|
- (a), above;
|
||||||
|
- (b), the metric call's orphan curl in `ledger.mjs`, the same fix as
|
||||||
|
n1 in round 1.
|
||||||
|
Neither is E's scope.
|
||||||
|
|
||||||
|
## For Darkwing and Sage
|
||||||
|
|
||||||
|
E is approved as it stands. My plan amendment (68a25ffe…) and both review
|
||||||
|
files go into E's commit.
|
||||||
+114
-10
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
|
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
|
||||||
request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's
|
request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's
|
||||||
thread messages in `~/.t3/userdata/state.sqlite`.
|
thread messages in `~/.t3/userdata/state.sqlite`. Above the counts, a queue
|
||||||
|
section checks `docs/plans/queue.json` against Gitea and the seat
|
||||||
|
registrations (see "Queue section").
|
||||||
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
|
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -11,6 +13,8 @@ node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
|
|||||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
|
||||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3
|
||||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-queue
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-06 --unsupported-runtime dewey
|
||||||
node --test packages/ledger/tests/
|
node --test packages/ledger/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,27 +129,127 @@ directory means no Pi seats ran here; a missing T3 database means the path or
|
|||||||
T3 changed, so it refuses instead of counting zero. Error messages name ids
|
T3 changed, so it refuses instead of counting zero. Error messages name ids
|
||||||
and paths, never message text.
|
and paths, never message text.
|
||||||
|
|
||||||
## One Gitea call and missing evidence
|
## Queue section
|
||||||
|
|
||||||
The client requests issues updated since the start date, all states, first page,
|
The rules come from plan 8.10 in
|
||||||
limit 50. This includes issues closed in range, even if later updated. Gitea caps
|
`agents/filbert/work/queue-as-data-plan-2026-09-26.md` (Piece E, #1508). The
|
||||||
|
section is on by default and prints above the weekly table; `--json` puts it
|
||||||
|
under `queue`. `--no-queue` skips it and prints `Queue: not checked
|
||||||
|
(--no-queue)`. The ledger reads `docs/plans/queue.json` through the queue's own
|
||||||
|
validator before any Gitea call, so a missing, symlinked or hand-edited file
|
||||||
|
exits 1 and costs no call. It writes nothing, takes no queue lock and never
|
||||||
|
changes a row.
|
||||||
|
|
||||||
|
Four checks, each finding named by (check, row, issue):
|
||||||
|
|
||||||
|
- **Issues.** An issue is expected closed once every row whose `closes`
|
||||||
|
includes it is done (J6). Then an open issue is `issue-open` on each of
|
||||||
|
those rows. A row that names an issue in `issues` but not in `closes` is
|
||||||
|
never checked against it, so rows 9 to 12 can be done while #1508 is open.
|
||||||
|
A closed issue with a row that closes it still pending is printed as a
|
||||||
|
`disposition` for the lead. It is not a violation.
|
||||||
|
- **Owners.** Each owner of an `in-progress` or `in-review` row gets one
|
||||||
|
liveness class from its `repo` registration in `<dataRoot>/seats`:
|
||||||
|
- `exempt`: declared on this run with `--unsupported-runtime SEAT`.
|
||||||
|
- `missing`: no registration, or one written for another checkout.
|
||||||
|
- `invalid`: the registration fails validation, or the config that names
|
||||||
|
the data root can't be read. A malformed or absent pid fails validation,
|
||||||
|
so it lands here, not in `pid-unknown`.
|
||||||
|
- `pid-unknown`: a valid registration with a null pid.
|
||||||
|
- `pid-gone`: the recorded pid is not running.
|
||||||
|
- `pid-present`: the pid is running. A registration holds no process-start
|
||||||
|
identity and a reused pid looks the same, so this reads "pid present
|
||||||
|
(identity not verified)".
|
||||||
|
The coverage line counts them: `liveness: N pid-present (unverified), N
|
||||||
|
exempt, N pid-unknown, N missing, N invalid, N pid-gone`.
|
||||||
|
- **Age.** A required row that is not done and whose `requiredSince` is more
|
||||||
|
than 14 whole days before the run is listed by name. `requiredSince` is a
|
||||||
|
date at genesis and an ISO time once `set required` or `add --required`
|
||||||
|
writes it; both count from 00:00Z of their UTC day. A value that doesn't
|
||||||
|
parse as a date is an `age-invalid` violation. A legacy row with
|
||||||
|
`requiredSince: "unknown"` was required no later than genesis, so once
|
||||||
|
genesis is more than 14 days old it is listed as `age ≥ N days (legacy lower
|
||||||
|
bound)`. Before that its age is undecided. Age runs to the time of the run,
|
||||||
|
not to `--until`.
|
||||||
|
- **Protected changes.** Every log entry dated inside the report range that
|
||||||
|
changes a required or parked row is listed with its revision, verb, claimed
|
||||||
|
actor and rows. The queue trusts `--by` (its README, "Trust boundary"), so
|
||||||
|
this list is how a wrong claim gets seen. It is not a check and never
|
||||||
|
changes the result. Jason confirms the actors weekly.
|
||||||
|
|
||||||
|
Every run ends with `queue: N violations; result R`, where R is one of three:
|
||||||
|
|
||||||
|
- `fail`: any violation. That is an open issue, a `missing`, `invalid` or
|
||||||
|
`pid-gone` owner, or an age.
|
||||||
|
- `incomplete`: no violation, but something undecided. That is an issue
|
||||||
|
whose state is unknown, a full open-issue page with some issue left
|
||||||
|
unknown, issue checks not run, a
|
||||||
|
`pid-unknown` owner, or a legacy age before genesis is 14 days old.
|
||||||
|
- `reduced pass`: nothing known and nothing undecided. There is no full pass,
|
||||||
|
because no owner's process identity is ever verified.
|
||||||
|
|
||||||
|
The exit code stays 0 whenever a report was computed; the result is in the
|
||||||
|
text and the JSON.
|
||||||
|
|
||||||
|
### Queue issue calls
|
||||||
|
|
||||||
|
The metric call can't answer "is this issue closed", because an issue nobody
|
||||||
|
touched this week isn't in it. So the queue checks have their own budget, per
|
||||||
|
plan 8.10:
|
||||||
|
|
||||||
|
| Calls | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| 1 | `state=open`, issues only, limit 50, one page. A full page may be short. Issues missing from it are looked up, so it makes the run `incomplete` only while some issue a row closes has no known state (lead decision 40). |
|
||||||
|
| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on the metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". |
|
||||||
|
|
||||||
|
With the metric call that is at most 12 calls. `--no-issues` makes none and
|
||||||
|
prints `queue issue checks: not run`. Closed needs positive evidence: an
|
||||||
|
entry on the metric page with state `closed` and a `closed_at`, or a lookup
|
||||||
|
returning a closed issue that isn't a pull request. A failed lookup, a 404, a
|
||||||
|
pull request or a mismatched number leaves the issue unknown. A failed or
|
||||||
|
malformed open-list call exits 2, like the metric call. Each queue call runs
|
||||||
|
under `timeout -s KILL 60`, which kills the helper and its curl together.
|
||||||
|
|
||||||
|
### Weekly routine
|
||||||
|
|
||||||
|
Run the ledger each Monday for the week that ended on Saturday, Sunday
|
||||||
|
through Saturday as row 7 counts it, with the queue section on and no
|
||||||
|
`--no-issues`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26
|
||||||
|
```
|
||||||
|
|
||||||
|
Post the dated run on #1508 with its coverage line and result. Any seat
|
||||||
|
whose runtime writes no registration (T3 seats today) is passed with
|
||||||
|
`--unsupported-runtime SEAT`, which the output prints. A violation is
|
||||||
|
remediated when a second dated run on the same UTC day no longer reports its
|
||||||
|
(check, row, issue), and both runs are posted on #1508. A row edit alone is
|
||||||
|
not remediation, and a `note` can't clear an owner finding, because that
|
||||||
|
check reads registrations. Row 13's gate is one Monday run with zero
|
||||||
|
violations, or every violation remediated that day.
|
||||||
|
|
||||||
|
## Gitea calls and missing evidence
|
||||||
|
|
||||||
|
The metric client requests issues updated since the start date, all states,
|
||||||
|
first page, limit 50. This includes issues closed in range, even if later updated. Gitea caps
|
||||||
responses at 50; a full page fails rather than silently reporting partial totals.
|
responses at 50; a full page fails rather than silently reporting partial totals.
|
||||||
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
|
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
|
||||||
issue not returned by the updated-since query still has a row, with unknown
|
issue not returned by the updated-since query still has a row, with unknown
|
||||||
metadata. This is the cost of the brief's one-call boundary.
|
metadata. This is the cost of the brief's one-call boundary.
|
||||||
|
|
||||||
Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git,
|
Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json
|
||||||
session or T3 evidence. Malformed JSONL, including a partially written last line,
|
the validator refuses, or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line,
|
||||||
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
|
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
|
||||||
credentials, API, payload, or completeness failure. The CLI never prints API
|
credentials, API, payload, or completeness failure. The CLI never prints API
|
||||||
error bodies or reads authentication files itself. `--no-issues` makes no API
|
error bodies or reads authentication files itself. The queue's open-list call
|
||||||
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
|
fails with exit 2 the same way. `--no-issues` makes no API call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
|
||||||
median duration, and human-per-closed ratio. It cannot invent close-only rows.
|
median duration, and human-per-closed ratio. It cannot invent close-only rows.
|
||||||
|
|
||||||
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
|
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
|
||||||
repository scripts directory is appended to PATH for the issue request.
|
repository scripts directory is appended to PATH for the issue request.
|
||||||
Tests use only temporary repositories, logs, T3 databases and fake API tools,
|
Tests use only temporary repositories, logs, T3 databases, queue files, seat
|
||||||
with no real credentials or network. Every CLI run in the tests sets `HOME` to
|
registrations, configs and fake API tools, with no real credentials or network. Every CLI run in the tests sets `HOME` to
|
||||||
a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential
|
a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential
|
||||||
read and checks successful GET, successful POST, and failed HTTP status.
|
read and checks successful GET, successful POST, and failed HTTP status.
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,29 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
|
import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
|
||||||
import { readT3, defaultT3Path } from './t3.mjs';
|
import { readT3, defaultT3Path } from './t3.mjs';
|
||||||
|
import { readQueue, readSeatsDir, issueStates, queueChecks, formatQueue, checkSeatName } from './queue-checks.mjs';
|
||||||
|
|
||||||
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH]';
|
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH] [--no-queue | --unsupported-runtime SEAT ...]';
|
||||||
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
|
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
|
||||||
let since, until, t3Db, json = false, noIssues = false, noT3 = false;
|
let since, until, t3Db, json = false, noIssues = false, noT3 = false, noQueue = false;
|
||||||
|
const exempt = new Set();
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const flag = args[i];
|
const flag = args[i];
|
||||||
|
// A runtime is declared unsupported once per seat, so only that flag repeats.
|
||||||
|
if (flag === '--unsupported-runtime') {
|
||||||
|
const seat = checkSeatName(args[++i]);
|
||||||
|
if (exempt.has(seat)) throw new SourceError(`Duplicate --unsupported-runtime ${seat}`);
|
||||||
|
exempt.add(seat);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
|
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
|
||||||
seen.add(flag);
|
seen.add(flag);
|
||||||
if (flag === '--help') { console.log(usage); return; }
|
if (flag === '--help') { console.log(usage); return; }
|
||||||
if (flag === '--json') json = true;
|
if (flag === '--json') json = true;
|
||||||
else if (flag === '--no-issues') noIssues = true;
|
else if (flag === '--no-issues') noIssues = true;
|
||||||
else if (flag === '--no-t3') noT3 = true;
|
else if (flag === '--no-t3') noT3 = true;
|
||||||
|
else if (flag === '--no-queue') noQueue = true;
|
||||||
else if (flag === '--t3-db') {
|
else if (flag === '--t3-db') {
|
||||||
t3Db = args[++i];
|
t3Db = args[++i];
|
||||||
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
|
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
|
||||||
@@ -27,20 +37,28 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
|
|||||||
}
|
}
|
||||||
if (!since) throw new SourceError(usage);
|
if (!since) throw new SourceError(usage);
|
||||||
if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined');
|
if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined');
|
||||||
|
if (noQueue && exempt.size) throw new SourceError('--no-queue and --unsupported-runtime cannot be combined');
|
||||||
const range = dateRange(since, until);
|
const range = dateRange(since, until);
|
||||||
|
// The queue loads before any Gitea call, so a bad queue.json costs none.
|
||||||
|
const queue = noQueue ? null : readQueue(root);
|
||||||
const commits = readCommits(root, range);
|
const commits = readCommits(root, range);
|
||||||
// Fixture tools may be placed first on PATH. The repository client is the
|
// Fixture tools may be placed first on PATH. The repository client is the
|
||||||
// default without requiring installation or reading auth material here.
|
// default without requiring installation or reading auth material here.
|
||||||
const priorPath = process.env.PATH;
|
const priorPath = process.env.PATH;
|
||||||
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
|
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
|
||||||
let issues;
|
let issues, states = null;
|
||||||
try { issues = noIssues ? null : readIssues(root, range); }
|
try {
|
||||||
|
issues = noIssues ? null : readIssues(root, range);
|
||||||
|
if (queue && !noIssues) states = issueStates(root, queue.rows, issues);
|
||||||
|
}
|
||||||
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
|
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
|
||||||
// T3 is on by default. A missing or unreadable database refuses the report.
|
// T3 is on by default. A missing or unreadable database refuses the report.
|
||||||
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
|
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
|
||||||
const sessions = mergeSources(await readSessions(root, range), t3);
|
const sessions = mergeSources(await readSessions(root, range), t3);
|
||||||
const report = summarize(range, commits, issues, sessions);
|
const report = summarize(range, commits, issues, sessions);
|
||||||
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
|
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
|
||||||
|
// The queue section prints above the weekly table.
|
||||||
|
console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n'));
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// The queue section (Piece E, #1508; plan 8.10). It reads
|
||||||
|
// docs/plans/queue.json through the queue's own validator, the owners'
|
||||||
|
// registrations through the seat package, and Gitea through the same helper
|
||||||
|
// as the metric call. It writes nothing. The best result is `reduced pass`:
|
||||||
|
// a registration holds a pid, not a process identity, so no owner is ever
|
||||||
|
// verified live.
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { lstatSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { applyEntry, loadDoc, replay, resolvedFromResult, rowsArray, sameJson } from '../../queue/src/queue.mjs';
|
||||||
|
import { SEAT_NAME, defaultConfigPath, loadDataRoot, readRegistration, samePath, seatsDir } from '../../seat/src/seat.mjs';
|
||||||
|
import { SourceError, clean } from './ledger.mjs';
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
export const AGE_LIMIT_DAYS = 14;
|
||||||
|
export const GITEA_DEADLINE_S = 60;
|
||||||
|
export const OPEN_LIMIT = 50;
|
||||||
|
export const LOOKUP_BUDGET = 10;
|
||||||
|
export const LIVENESS = ['pid-present', 'exempt', 'pid-unknown', 'missing', 'invalid', 'pid-gone'];
|
||||||
|
const ACTIVE = new Set(['in-progress', 'in-review']);
|
||||||
|
|
||||||
|
export function checkSeatName(seat) {
|
||||||
|
if (typeof seat !== 'string' || !SEAT_NAME.test(seat)) throw new SourceError(`--unsupported-runtime needs a seat name, not ${JSON.stringify(clean(seat ?? ''))}`);
|
||||||
|
return seat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any refusal from the validator refuses the report: a queue that doesn't
|
||||||
|
// load has no rows to check.
|
||||||
|
export function readQueue(root) {
|
||||||
|
const file = path.join(root, 'docs/plans/queue.json');
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
if (!lstatSync(file).isFile()) throw new Error('not a regular file');
|
||||||
|
bytes = readFileSync(file);
|
||||||
|
} catch { throw new SourceError('Queue unavailable: docs/plans/queue.json is missing or not a regular file; use --no-queue to skip the queue section'); }
|
||||||
|
try {
|
||||||
|
const { doc } = loadDoc(bytes);
|
||||||
|
return { revision: doc.revision, canonicalRoot: doc.canonicalRoot, genesisAt: doc.log[0].at, rows: doc.rows, log: doc.log };
|
||||||
|
} catch (error) { throw new SourceError(`Queue unavailable: ${clean(error.message)}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal 0 probe. EPERM still means a process has the pid.
|
||||||
|
export function pidAlive(pid) {
|
||||||
|
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One class per owner seat, from its repo-layout registration for the
|
||||||
|
// queue's canonical root. A registration for the same seat name written by
|
||||||
|
// another checkout says nothing about this one, so it counts as missing.
|
||||||
|
export function classifySeat(seat, { seats, canonicalRoot, exempt, isPidAlive = pidAlive }) {
|
||||||
|
if (exempt.has(seat)) return { class: 'exempt', detail: 'declared with --unsupported-runtime' };
|
||||||
|
if (seats === null) return { class: 'invalid', detail: 'no seats directory: the config is unreadable' };
|
||||||
|
let record;
|
||||||
|
try { record = readRegistration(seats, seat, 'repo'); } catch { return { class: 'invalid', detail: 'the registration does not validate' }; }
|
||||||
|
if (record === null) return { class: 'missing', detail: 'no repo registration' };
|
||||||
|
if (!samePath(record.sessionsDir, path.join(canonicalRoot, '.pi', 'state', seat, 'sessions'))) {
|
||||||
|
return { class: 'missing', detail: 'the repo registration is for another checkout' };
|
||||||
|
}
|
||||||
|
if (record.pid === null) return { class: 'pid-unknown', detail: 'the registration has no pid' };
|
||||||
|
return isPidAlive(record.pid) ? { class: 'pid-present', detail: 'pid present (identity not verified)' } : { class: 'pid-gone', detail: 'the recorded pid is not running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seats directory from the system config. A config that can't be read
|
||||||
|
// makes every non-exempt owner invalid rather than refusing the whole report.
|
||||||
|
export function readSeatsDir(configPath = defaultConfigPath()) {
|
||||||
|
try { return seatsDir(loadDataRoot(configPath)); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One helper call under the deadline. `timeout -s KILL` kills the helper's
|
||||||
|
// process group, curl included, as the queue's review call does; a Node
|
||||||
|
// timeout would kill the helper and leave curl running.
|
||||||
|
function gitea(root, apiPath, tool, deadline) {
|
||||||
|
const r = spawnSync('timeout', ['-s', 'KILL', String(deadline), tool, 'GET', apiPath], { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
if (r.error) throw r.error;
|
||||||
|
if (r.status !== 0) throw Object.assign(new Error('Gitea call failed'), { status: r.status, killed: r.signal === 'SIGKILL' || r.status === 137 });
|
||||||
|
return r.stdout;
|
||||||
|
}
|
||||||
|
const issueRecord = i => i && Number.isSafeInteger(i.number) && i.number > 0 && (i.state === 'open' || i.state === 'closed');
|
||||||
|
|
||||||
|
// The open list: one call, one page. A failure refuses the report the way
|
||||||
|
// the metric call does. A full page may be short, but an issue missing from
|
||||||
|
// it is looked up, so the page is undecided only while some issue a row
|
||||||
|
// closes has no known state (lead decision 40).
|
||||||
|
export function readOpenIssues(root, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) {
|
||||||
|
let output;
|
||||||
|
try { output = gitea(root, `repos/mosaicstack/stack/issues?state=open&type=issues&limit=${OPEN_LIMIT}&page=1`, tool, deadline); }
|
||||||
|
catch (error) {
|
||||||
|
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
|
||||||
|
error.killed ? `no answer within ${deadline} s` :
|
||||||
|
error.status === 126 || error.status === 127 ? 'gitea-api.sh unavailable' :
|
||||||
|
error.code === 'ENOENT' ? 'the timeout command is unavailable' : 'credential or Gitea request failure';
|
||||||
|
throw new SourceError(`Open issues unavailable: ${reason}; use --no-issues to leave the queue issue checks unrun`, 2);
|
||||||
|
}
|
||||||
|
let list;
|
||||||
|
try { list = JSON.parse(output); } catch { throw new SourceError('Open issues unavailable: invalid Gitea JSON', 2); }
|
||||||
|
if (!Array.isArray(list) || !list.every(issueRecord)) throw new SourceError('Open issues unavailable: invalid Gitea issue records', 2);
|
||||||
|
const issues = list.filter(i => !i.pull_request);
|
||||||
|
if (issues.some(i => i.state !== 'open')) throw new SourceError('Open issues unavailable: the open list holds a closed issue', 2);
|
||||||
|
return { numbers: new Set(issues.map(i => i.number)), full: list.length >= OPEN_LIMIT };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One issue by number. Anything but an issue with that number and a state is
|
||||||
|
// no evidence: a 404, a pull request, a failed call.
|
||||||
|
export function lookupIssue(root, number, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) {
|
||||||
|
let issue;
|
||||||
|
try { issue = JSON.parse(gitea(root, `repos/mosaicstack/stack/issues/${number}`, tool, deadline)); }
|
||||||
|
catch { return { state: 'unknown', detail: 'lookup failed' }; }
|
||||||
|
if (!issueRecord(issue) || issue.number !== number) return { state: 'unknown', detail: 'lookup returned no issue record' };
|
||||||
|
if (issue.pull_request) return { state: 'unknown', detail: 'the number is a pull request' };
|
||||||
|
return { state: issue.state, detail: 'lookup' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue states for every issue a row closes. `metric` is the metric call's
|
||||||
|
// list (issues only) or null when it wasn't made. Returns the states, the
|
||||||
|
// number of lookups made, and whether the open list was a full page.
|
||||||
|
export function issueStates(root, rows, metric, tool = 'gitea-api.sh') {
|
||||||
|
const wanted = [...new Set(rows.flatMap(r => r.closes))].sort((a, b) => a - b);
|
||||||
|
const open = readOpenIssues(root, tool);
|
||||||
|
// Closed evidence needs both fields: a reopened issue off the full open
|
||||||
|
// page must not count as closed on a stale closed_at.
|
||||||
|
const closedInMetric = new Set((metric ?? []).filter(i => i.state === 'closed' && i.closed_at).map(i => i.number));
|
||||||
|
const states = new Map();
|
||||||
|
let lookups = 0;
|
||||||
|
for (const n of wanted) {
|
||||||
|
if (open.numbers.has(n)) states.set(n, { state: 'open', detail: 'open list' });
|
||||||
|
else if (closedInMetric.has(n)) states.set(n, { state: 'closed', detail: 'metric page' });
|
||||||
|
else if (lookups < LOOKUP_BUDGET) { lookups++; states.set(n, lookupIssue(root, n, tool)); }
|
||||||
|
else states.set(n, { state: 'unknown', detail: 'over the lookup budget' });
|
||||||
|
}
|
||||||
|
return { states, lookups, openListFull: open.full };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every log entry in the report range that changes a required or parked row,
|
||||||
|
// with the actor it claims, for Jason to confirm weekly. The queue trusts
|
||||||
|
// `--by` (its README, "Trust boundary"), so this list is the detection. A row
|
||||||
|
// counts when it is required or parked before or after the entry. The log has
|
||||||
|
// already replayed in loadDoc, so replaying it again here cannot refuse.
|
||||||
|
export function protectedChanges(log, range) {
|
||||||
|
const inRange = e => { const ms = Date.parse(e.at); return ms >= range.start && ms < range.end; };
|
||||||
|
const first = log.findIndex(inRange);
|
||||||
|
if (first === -1) return [];
|
||||||
|
const guarded = r => r !== undefined && (r.required || r.state === 'parked');
|
||||||
|
const changes = [];
|
||||||
|
let state = first === 0 ? { rows: new Map() } : replay(log.slice(0, first));
|
||||||
|
for (let i = first; i < log.length; i++) {
|
||||||
|
const e = log[i];
|
||||||
|
const next = i === 0 ? replay(log.slice(0, 1)) : applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result)).state;
|
||||||
|
if (inRange(e)) {
|
||||||
|
const rows = rowsArray(next).filter(r => !sameJson(r, state.rows.get(r.id)) && (guarded(r) || guarded(state.rows.get(r.id)))).map(r => r.id);
|
||||||
|
if (rows.length) changes.push({ rev: e.rev, op: e.op, verb: e.verb, by: e.by, at: e.at, rows });
|
||||||
|
}
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageDays = (fromMs, now) => Math.floor((now - fromMs) / DAY);
|
||||||
|
// requiredSince is a date (the genesis form) or an ISO time (`set required`
|
||||||
|
// and `add --required` write one). Date.parse reads a bare date as 00:00Z,
|
||||||
|
// and either counts from 00:00Z of its UTC day, so age is whole UTC days
|
||||||
|
// whatever the hour. The queue validator checks the shape, not the
|
||||||
|
// calendar, so a value that doesn't parse is NaN.
|
||||||
|
const requiredDay = v => Math.floor(Date.parse(v) / DAY) * DAY;
|
||||||
|
|
||||||
|
// The four checks. `issues` is null when the issue checks did not run.
|
||||||
|
// Each finding has an identity (check, row, issue): a second run on the same
|
||||||
|
// UTC day that no longer reports it is the remediation (plan 8.10).
|
||||||
|
export function queueChecks(queue, { issues, seats, exempt = new Set(), now = Date.now(), isPidAlive = pidAlive, range = null }) {
|
||||||
|
const violations = [], undecided = [], dispositions = [];
|
||||||
|
const add = (list, check, row, issue, message) => list.push({ check, row, issue, message });
|
||||||
|
const rows = queue.rows;
|
||||||
|
|
||||||
|
if (issues) {
|
||||||
|
const closers = new Map();
|
||||||
|
for (const r of rows) for (const n of r.closes) closers.set(n, [...(closers.get(n) ?? []), r]);
|
||||||
|
for (const [n, rs] of [...closers].sort((a, b) => a[0] - b[0])) {
|
||||||
|
const { state, detail } = issues.states.get(n);
|
||||||
|
const allDone = rs.every(r => r.state === 'done');
|
||||||
|
for (const r of rs.filter(r => r.state === 'done')) {
|
||||||
|
if (!allDone) continue;
|
||||||
|
if (state === 'open') add(violations, 'issue-open', r.id, n, `row ${r.id} is done and every row that closes #${n} is done, but #${n} is open`);
|
||||||
|
else if (state === 'unknown') add(undecided, 'issue-unknown', r.id, n, `row ${r.id} is done; whether #${n} is closed is unknown (${detail})`);
|
||||||
|
}
|
||||||
|
if (state === 'closed' && !allDone) {
|
||||||
|
const pending = rs.filter(r => r.state !== 'done').map(r => r.id);
|
||||||
|
add(dispositions, 'issue-closed-early', pending[0], n, `#${n} is closed, but row${pending.length > 1 ? 's' : ''} ${pending.join(', ')} that close${pending.length > 1 ? '' : 's'} it ${pending.length > 1 ? 'are' : 'is'} not done`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unresolved = [...issues.states].filter(([, s]) => s.state === 'unknown').map(([n]) => `#${n}`);
|
||||||
|
if (issues.openListFull && unresolved.length) {
|
||||||
|
add(undecided, 'open-list-full', null, null, `the open-issue list was a full page of ${OPEN_LIMIT}, and ${unresolved.join(', ')} ${unresolved.length === 1 ? 'has' : 'have'} no known state`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add(undecided, 'issues-not-run', null, null, 'the issue checks did not run (--no-issues)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const owners = new Map();
|
||||||
|
for (const r of rows.filter(r => ACTIVE.has(r.state))) owners.set(r.owner, [...(owners.get(r.owner) ?? []), r.id]);
|
||||||
|
const liveness = [];
|
||||||
|
for (const [seat, ids] of [...owners].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
const c = classifySeat(seat, { seats, canonicalRoot: queue.canonicalRoot, exempt, isPidAlive });
|
||||||
|
liveness.push({ seat, rows: ids, ...c });
|
||||||
|
for (const id of ids) {
|
||||||
|
const message = `row ${id} owner ${seat}: ${c.class}, ${c.detail}`;
|
||||||
|
if (c.class === 'missing' || c.class === 'invalid' || c.class === 'pid-gone') add(violations, `owner-${c.class}`, id, null, message);
|
||||||
|
else if (c.class === 'pid-unknown') add(undecided, 'owner-pid-unknown', id, null, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const genesisDays = ageDays(Date.parse(queue.genesisAt), now);
|
||||||
|
for (const r of rows.filter(r => r.required && r.state !== 'done')) {
|
||||||
|
if (r.requiredSince === 'unknown') {
|
||||||
|
if (genesisDays > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, age ≥ ${genesisDays} days (legacy lower bound)`);
|
||||||
|
else add(undecided, 'age-unknown', r.id, null, `row ${r.id} is required and not done; its age is unknown until genesis is over ${AGE_LIMIT_DAYS} days old`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const from = requiredDay(r.requiredSince);
|
||||||
|
if (!Number.isFinite(from)) {
|
||||||
|
add(violations, 'age-invalid', r.id, null, `row ${r.id} is required and not done, and its requiredSince ${JSON.stringify(clean(r.requiredSince))} is not a date`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const days = ageDays(from, now);
|
||||||
|
if (days > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, ${days} days since ${r.requiredSince}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = Object.fromEntries(LIVENESS.map(k => [k, liveness.filter(s => s.class === k).length]));
|
||||||
|
const result = violations.length ? 'fail' : undecided.length ? 'incomplete' : 'reduced pass';
|
||||||
|
return {
|
||||||
|
checked: true, revision: queue.revision, asOf: new Date(now).toISOString(), result,
|
||||||
|
violations, undecided, dispositions,
|
||||||
|
liveness: { counts, seats: liveness, exempt: [...exempt].sort() },
|
||||||
|
issueChecks: issues ? { run: true, lookups: issues.lookups, openListFull: issues.openListFull } : { run: false },
|
||||||
|
protectedChanges: range && queue.log ? protectedChanges(queue.log, range) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = f => [f.row === null ? null : `row ${f.row}`, f.issue === null ? null : `#${f.issue}`].filter(Boolean).join(' ');
|
||||||
|
export function formatQueue(q) {
|
||||||
|
if (!q.checked) return ['Queue: not checked (--no-queue)'];
|
||||||
|
const c = q.liveness.counts;
|
||||||
|
return [
|
||||||
|
`Queue checks: queue.json revision ${q.revision}, as of ${q.asOf}`,
|
||||||
|
...q.violations.map(f => `violation ${f.check} ${where(f)}: ${f.message}`),
|
||||||
|
...q.undecided.map(f => `undecided ${f.check}${f.row === null && f.issue === null ? '' : ` ${where(f)}`}: ${f.message}`),
|
||||||
|
...q.dispositions.map(f => `disposition ${f.check} ${where(f)}: ${f.message}`),
|
||||||
|
...q.liveness.seats.map(s => `owner ${s.seat} (row${s.rows.length === 1 ? '' : 's'} ${s.rows.join(', ')}): ${s.class}, ${s.detail}`),
|
||||||
|
`liveness: ${c['pid-present']} pid-present (unverified), ${c.exempt} exempt, ${c['pid-unknown']} pid-unknown, ${c.missing} missing, ${c.invalid} invalid, ${c['pid-gone']} pid-gone`,
|
||||||
|
...(q.liveness.exempt.length ? [`declared unsupported runtime: ${q.liveness.exempt.join(', ')}`] : []),
|
||||||
|
q.issueChecks.run ? `queue issue checks: open list${q.issueChecks.openListFull ? ' (full page)' : ''}, ${q.issueChecks.lookups} lookup${q.issueChecks.lookups === 1 ? '' : 's'}` : 'queue issue checks: not run',
|
||||||
|
...q.protectedChanges.map(c => `protected change rev ${c.rev} ${c.verb} by ${c.by} at ${c.at}: row${c.rows.length === 1 ? '' : 's'} ${c.rows.join(', ')}`),
|
||||||
|
`protected changes in range: ${q.protectedChanges.length} (not checks; confirm the actors)`,
|
||||||
|
`queue: ${q.violations.length} violation${q.violations.length === 1 ? '' : 's'}; result ${q.result}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -64,13 +64,16 @@ function fixture(t) {
|
|||||||
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
|
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
|
||||||
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
|
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
|
||||||
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
||||||
|
// The queue section imports the queue validator and the seat registry.
|
||||||
|
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
||||||
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
|
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
|
||||||
execFileSync('chmod', ['+x', api]);
|
execFileSync('chmod', ['+x', api]);
|
||||||
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
|
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
|
||||||
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
||||||
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
|
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
|
||||||
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
|
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
|
||||||
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
|
// These tests cover the weekly table; queue-checks.test.mjs covers the queue section.
|
||||||
|
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
|
||||||
return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
|
return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
|
||||||
}
|
}
|
||||||
test('fixture git subjects only, follow-ups and three session kinds', t => {
|
test('fixture git subjects only, follow-ups and three session kinds', t => {
|
||||||
@@ -103,7 +106,7 @@ test('missing credentials exit 2, no-issues never calls API and shows unknown',
|
|||||||
test('empty range gives no rows and zero totals', t => {
|
test('empty range gives no rows and zero totals', t => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
f.put('issues.json', '[]');
|
f.put('issues.json', '[]');
|
||||||
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
|
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json', '--no-queue'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
|
||||||
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
|
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
|
||||||
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
|
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// The ledger's queue section (Piece E, #1508; plan 8.10). The checks run
|
||||||
|
// in-process on fixture rows with an injected clock and pid probe. The CLI
|
||||||
|
// runs in a scratch repository whose queue.json the real queue writer made,
|
||||||
|
// against a fake gitea-api.sh. No test reads a real credential, registration
|
||||||
|
// or config: every run sets HOME and MOSAIC_CONFIG to temporary paths.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { queueChecks, formatQueue, issueStates, readQueue, readOpenIssues, lookupIssue, classifySeat, pidAlive, protectedChanges, LOOKUP_BUDGET } from '../src/queue-checks.mjs';
|
||||||
|
import { writeRegistration } from '../../seat/src/seat.mjs';
|
||||||
|
import { scratchRepo, genesisCommitted, mapText, MAP_ROWS, cli as queueCli } from '../../queue/tests/helpers.mjs';
|
||||||
|
|
||||||
|
const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const DAY = 86400000;
|
||||||
|
const NOW = Date.parse('2026-09-28T09:00:00Z');
|
||||||
|
const ROOT = '/srv/checkout';
|
||||||
|
|
||||||
|
const row = (id, over = {}) => ({ id, piece: `Row ${id}`, owner: 'alice', issues: [], closes: [], state: 'briefed', required: false, requiredSince: null, ...over });
|
||||||
|
const queue = (rows, genesisAt = '2026-09-27T01:00:00Z') => ({ revision: 7, canonicalRoot: ROOT, genesisAt, rows });
|
||||||
|
const states = (map, { lookups = 0, openListFull = false } = {}) => ({
|
||||||
|
states: new Map(Object.entries(map).map(([n, state]) => [Number(n), { state, detail: state === 'unknown' ? 'lookup failed' : 'lookup' }])),
|
||||||
|
lookups, openListFull,
|
||||||
|
});
|
||||||
|
// Seats directory with one registration per entry: [seat, pid, root].
|
||||||
|
function seatsWith(t, entries) {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-seats-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
for (const [seat, pid, root = ROOT] of entries) {
|
||||||
|
writeRegistration(dir, { version: 1, seat, project: 'checkout', task: '', workspace: root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z',
|
||||||
|
pid, sessionsDir: path.join(root, '.pi/state', seat, 'sessions'), seatDir: path.join(root, 'agents', seat), launchScript: path.join(root, 'agents', seat, 'launch.sh'), layout: 'repo', updatedAt: null });
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const run = (rows, { issues = states({}), seats = null, exempt = [], genesisAt, isPidAlive = () => true } = {}) =>
|
||||||
|
queueChecks(queue(rows, genesisAt), { issues, seats, exempt: new Set(exempt), now: NOW, isPidAlive });
|
||||||
|
const ids = list => list.map(f => [f.check, f.row, f.issue]);
|
||||||
|
|
||||||
|
test('a done row whose closing issue is open is a violation; a row that is not done is not', () => {
|
||||||
|
const rows = [row(1, { state: 'done', issues: [101], closes: [101] }), row(2, { state: 'briefed', issues: [102], closes: [102] })];
|
||||||
|
const open = run(rows, { issues: states({ 101: 'open', 102: 'open' }) });
|
||||||
|
assert.deepEqual(ids(open.violations), [['issue-open', 1, 101]]);
|
||||||
|
assert.equal(open.result, 'fail');
|
||||||
|
const closed = run(rows, { issues: states({ 101: 'closed', 102: 'open' }) });
|
||||||
|
assert.deepEqual([closed.violations, closed.undecided, closed.dispositions], [[], [], []]);
|
||||||
|
assert.equal(closed.result, 'reduced pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an issue several rows close is expected closed only once all of them are done', () => {
|
||||||
|
const rows = [row(1, { state: 'done', issues: [200], closes: [200] }), row(2, { state: 'in-progress', owner: 'bob', issues: [200], closes: [200] })];
|
||||||
|
const exempt = ['bob'];
|
||||||
|
assert.deepEqual(ids(run(rows, { issues: states({ 200: 'open' }), exempt }).violations), []);
|
||||||
|
const early = run(rows, { issues: states({ 200: 'closed' }), exempt });
|
||||||
|
assert.deepEqual(ids(early.dispositions), [['issue-closed-early', 2, 200]]);
|
||||||
|
assert.match(early.dispositions[0].message, /^#200 is closed, but row 2 that closes it is not done$/);
|
||||||
|
assert.equal(early.result, 'reduced pass', 'a disposition is printed, not counted');
|
||||||
|
const both = [rows[0], { ...rows[1], state: 'done' }];
|
||||||
|
assert.deepEqual(ids(run(both, { issues: states({ 200: 'open' }) }).violations), [['issue-open', 1, 200], ['issue-open', 2, 200]]);
|
||||||
|
// Rows 9 to 12 name #1508 without closing it: done while it is open is fine.
|
||||||
|
const named = [row(9, { state: 'done', issues: [1508], closes: [] }), row(13, { state: 'briefed', issues: [1508], closes: [1508] })];
|
||||||
|
const r = run(named, { issues: states({ 1508: 'open' }) });
|
||||||
|
assert.deepEqual([r.violations, r.result], [[], 'reduced pass']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closure needs positive evidence: unknown is undecided, and so is a skipped or short issue check', () => {
|
||||||
|
const rows = [row(1, { state: 'done', issues: [101], closes: [101] })];
|
||||||
|
const unknown = run(rows, { issues: states({ 101: 'unknown' }) });
|
||||||
|
assert.deepEqual([ids(unknown.undecided), unknown.result], [[['issue-unknown', 1, 101]], 'incomplete']);
|
||||||
|
assert.match(unknown.undecided[0].message, /whether #101 is closed is unknown \(lookup failed\)/);
|
||||||
|
const skipped = run(rows, { issues: null });
|
||||||
|
assert.deepEqual([ids(skipped.undecided), skipped.result, skipped.issueChecks], [[['issues-not-run', null, null]], 'incomplete', { run: false }]);
|
||||||
|
// A full page is undecided only while some issue a row closes is unknown.
|
||||||
|
const full = run(rows, { issues: states({ 101: 'closed' }, { openListFull: true }) });
|
||||||
|
assert.deepEqual([full.undecided, full.result], [[], 'reduced pass']);
|
||||||
|
const short = run([...rows, row(2, { issues: [102], closes: [102] })], { issues: states({ 101: 'closed', 102: 'unknown' }, { openListFull: true }) });
|
||||||
|
assert.deepEqual([ids(short.undecided), short.result], [[['open-list-full', null, null]], 'incomplete']);
|
||||||
|
assert.match(short.undecided[0].message, /full page of 50, and #102 has no known state$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each owner of an in-progress or in-review row gets one liveness class', t => {
|
||||||
|
const other = '/srv/another-clone';
|
||||||
|
const seats = seatsWith(t, [['present', 101], ['gone', 102], ['nopid', null], ['elsewhere', 103, other], ['exempted', 104]]);
|
||||||
|
mkdirSync(path.join(seats, 'repo', 'broken'), { recursive: true });
|
||||||
|
writeFileSync(path.join(seats, 'repo', 'broken', 'registration.json'), '{not json');
|
||||||
|
const owners = ['present', 'gone', 'nopid', 'elsewhere', 'exempted', 'broken', 'absent'];
|
||||||
|
const rows = [...owners.map((owner, i) => row(i + 1, { owner, state: i % 2 ? 'in-review' : 'in-progress' })),
|
||||||
|
row(20, { owner: 'idle', state: 'briefed' }), row(21, { owner: 'finished', state: 'done' }), row(22, { owner: 'present', state: 'in-progress' })];
|
||||||
|
const r = run(rows, { seats, exempt: ['exempted'], isPidAlive: pid => pid !== 102 });
|
||||||
|
assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class, s.rows]), [
|
||||||
|
['absent', 'missing', [7]], ['broken', 'invalid', [6]], ['elsewhere', 'missing', [4]], ['exempted', 'exempt', [5]],
|
||||||
|
['gone', 'pid-gone', [2]], ['nopid', 'pid-unknown', [3]], ['present', 'pid-present', [1, 22]],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(r.liveness.counts, { 'pid-present': 1, exempt: 1, 'pid-unknown': 1, missing: 2, invalid: 1, 'pid-gone': 1 });
|
||||||
|
assert.deepEqual(ids(r.violations).sort(), [['owner-invalid', 6, null], ['owner-missing', 4, null], ['owner-missing', 7, null], ['owner-pid-gone', 2, null]].sort());
|
||||||
|
assert.deepEqual(ids(r.undecided), [['owner-pid-unknown', 3, null]]);
|
||||||
|
assert.match(r.violations.find(f => f.row === 4).message, /another checkout/);
|
||||||
|
// Without a readable config there is no seats directory: invalid, never missing.
|
||||||
|
assert.deepEqual(classifySeat('present', { seats: null, canonicalRoot: ROOT, exempt: new Set() }).class, 'invalid');
|
||||||
|
// Nothing active, nothing checked: exempt seats with no rows add no count.
|
||||||
|
const idle = run([row(1)], { seats, exempt: ['present'] });
|
||||||
|
assert.deepEqual([idle.liveness.seats, idle.liveness.exempt, idle.result], [[], ['present'], 'reduced pass']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a required row not done after 14 days is a violation; a legacy row uses genesis as its lower bound', () => {
|
||||||
|
const since = days => new Date(NOW - days * DAY).toISOString().slice(0, 10);
|
||||||
|
const rows = [
|
||||||
|
row(1, { required: true, requiredSince: since(15) }), row(2, { required: true, requiredSince: since(14) }),
|
||||||
|
row(3, { required: true, requiredSince: since(40), state: 'done' }), row(4, { required: false, state: 'waiting-on-jason' }),
|
||||||
|
];
|
||||||
|
const r = run(rows);
|
||||||
|
assert.deepEqual(ids(r.violations), [['age', 1, null]]);
|
||||||
|
assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since \d{4}-\d{2}-\d{2}$/);
|
||||||
|
const legacy = [row(5, { required: true, requiredSince: 'unknown', state: 'waiting-on-jason' })];
|
||||||
|
const old = run(legacy, { genesisAt: new Date(NOW - 20 * DAY).toISOString() });
|
||||||
|
assert.deepEqual(ids(old.violations), [['age', 5, null]]);
|
||||||
|
assert.match(old.violations[0].message, /age ≥ 20 days \(legacy lower bound\)$/);
|
||||||
|
const edge = run(legacy, { genesisAt: new Date(NOW - 14 * DAY).toISOString() });
|
||||||
|
assert.deepEqual([ids(edge.violations), ids(edge.undecided)], [[], [['age-unknown', 5, null]]], 'exactly 14 days is not over 14');
|
||||||
|
const young = run(legacy, { genesisAt: new Date(NOW - 3 * DAY).toISOString() });
|
||||||
|
assert.deepEqual([ids(young.undecided), young.result], [[['age-unknown', 5, null]], 'incomplete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ISO requiredSince, as `set required` writes it, ages from its UTC day; one that does not parse is a violation', () => {
|
||||||
|
const at = ms => new Date(ms).toISOString();
|
||||||
|
const rows = [
|
||||||
|
row(1, { required: true, requiredSince: at(NOW - 15 * DAY) }), row(2, { required: true, requiredSince: at(NOW - 14 * DAY) }),
|
||||||
|
// 23:59Z fifteen days back is still that day: 15 whole days, not 14.
|
||||||
|
row(3, { required: true, requiredSince: '2026-09-13T23:59:59.999Z' }),
|
||||||
|
];
|
||||||
|
const r = run(rows);
|
||||||
|
assert.deepEqual(ids(r.violations), [['age', 1, null], ['age', 3, null]]);
|
||||||
|
assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since 2026-09-13T09:00:00\.000Z$/);
|
||||||
|
// The queue validator checks the shape, not the calendar.
|
||||||
|
const bad = run([row(4, { required: true, requiredSince: '2026-13-01T00:00:00.000Z' })]);
|
||||||
|
assert.deepEqual([ids(bad.violations), bad.result], [[['age-invalid', 4, null]], 'fail']);
|
||||||
|
assert.match(bad.violations[0].message, /^row 4 is required and not done, and its requiredSince "2026-13-01T00:00:00\.000Z" is not a date$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the text section always ends in a count and a result, and never prints a full pass', () => {
|
||||||
|
const clean = formatQueue(run([row(1)]));
|
||||||
|
assert.deepEqual(clean.slice(-4), ['queue issue checks: open list, 0 lookups', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 0 violations; result reduced pass', '']);
|
||||||
|
assert.ok(clean.includes('liveness: 0 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
||||||
|
const bad = formatQueue(run([row(1, { state: 'done', issues: [101], closes: [101] })], { issues: states({ 101: 'open' }, { lookups: 1 }) }));
|
||||||
|
assert.ok(bad.includes('violation issue-open row 1 #101: row 1 is done and every row that closes #101 is done, but #101 is open'));
|
||||||
|
assert.deepEqual(bad.slice(-4), ['queue issue checks: open list, 1 lookup', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 1 violation; result fail', '']);
|
||||||
|
assert.deepEqual(formatQueue({ checked: false }), ['Queue: not checked (--no-queue)']);
|
||||||
|
assert.ok(formatQueue(run([row(1)], { issues: null })).includes('queue issue checks: not run'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pidAlive: a running pid is present, an exited one is gone, and EPERM still means present', () => {
|
||||||
|
assert.equal(pidAlive(process.pid), true);
|
||||||
|
const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
|
||||||
|
assert.equal(pidAlive(Number(child.stdout)), false);
|
||||||
|
// pid 1 belongs to root: signal 0 from another user fails with EPERM.
|
||||||
|
if (process.getuid() !== 0) assert.equal(pidAlive(1), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A fake helper that answers the open list from OPEN, an issue from
|
||||||
|
// ISSUE_DIR/N.json (exit 1 when absent, like a 404), and logs every call.
|
||||||
|
function fakeTool(t) {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-fake-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const tool = path.join(dir, 'gitea-api.sh');
|
||||||
|
writeFileSync(tool, `#!/usr/bin/env node
|
||||||
|
const fs = require('fs'), p = process.argv[3];
|
||||||
|
fs.appendFileSync(process.env.CALLS, JSON.stringify(process.argv.slice(2)) + '\\n');
|
||||||
|
if (process.env.API_FAIL) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); }
|
||||||
|
if (p.includes('state=open')) { if (process.env.OPEN_FAIL) process.exit(Number(process.env.OPEN_FAIL)); process.stdout.write(fs.readFileSync(process.env.OPEN, 'utf8')); }
|
||||||
|
else if (/issues\\/\\d+$/.test(p)) { const f = process.env.ISSUE_DIR + '/' + p.split('/').pop() + '.json'; if (!fs.existsSync(f)) process.exit(1); process.stdout.write(fs.readFileSync(f, 'utf8')); }
|
||||||
|
else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8'));
|
||||||
|
`);
|
||||||
|
chmodSync(tool, 0o755);
|
||||||
|
mkdirSync(path.join(dir, 'issues'));
|
||||||
|
const env = { CALLS: path.join(dir, 'calls.jsonl'), OPEN: path.join(dir, 'open.json'), ISSUE_DIR: path.join(dir, 'issues'), ISSUES: path.join(dir, 'metric.json') };
|
||||||
|
writeFileSync(env.CALLS, ''); writeFileSync(env.OPEN, '[]'); writeFileSync(env.ISSUES, '[]');
|
||||||
|
const issue = (n, over = {}) => writeFileSync(path.join(env.ISSUE_DIR, `${n}.json`), JSON.stringify({ number: n, title: `Issue ${n}`, state: 'closed', ...over }));
|
||||||
|
const calls = () => readFileSync(env.CALLS, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)[1]);
|
||||||
|
return { dir, tool, env, issue, calls };
|
||||||
|
}
|
||||||
|
const openIssue = n => ({ number: n, title: `Issue ${n}`, state: 'open' });
|
||||||
|
|
||||||
|
test('issue states: open list first, then the metric page, then at most 10 lookups', t => {
|
||||||
|
const f = fakeTool(t);
|
||||||
|
Object.assign(process.env, f.env);
|
||||||
|
t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; });
|
||||||
|
writeFileSync(f.env.OPEN, JSON.stringify([openIssue(1), { ...openIssue(2), pull_request: {} }]));
|
||||||
|
const closes = [1, 2, 3, ...Array.from({ length: 12 }, (_, i) => 10 + i)];
|
||||||
|
const rows = closes.map(n => row(n, { issues: [n], closes: [n] }));
|
||||||
|
f.issue(10); f.issue(11, { state: 'open' }); f.issue(12, { pull_request: { merged: true } }); f.issue(13, { number: 99 });
|
||||||
|
// #10 is on the metric page but not closed there, so it still needs a lookup.
|
||||||
|
// #11 was reopened: a closed_at without state closed is no evidence either,
|
||||||
|
// and neither is state closed without a closed_at (#12).
|
||||||
|
const metric = [{ number: 3, state: 'closed', closed_at: '2026-09-20T00:00:00Z' }, { number: 10, state: 'open', closed_at: null },
|
||||||
|
{ number: 11, state: 'open', closed_at: '2026-09-21T00:00:00Z' }, { number: 12, state: 'closed', closed_at: null }];
|
||||||
|
const r = issueStates('/', rows, metric, f.tool);
|
||||||
|
const state = n => r.states.get(n);
|
||||||
|
assert.deepEqual([state(1).state, state(3), state(10), state(11).state], ['open', { state: 'closed', detail: 'metric page' }, { state: 'closed', detail: 'lookup' }, 'open']);
|
||||||
|
assert.deepEqual(state(2), { state: 'unknown', detail: 'lookup failed' });
|
||||||
|
assert.deepEqual([state(12).detail, state(13).detail, state(14).detail], ['the number is a pull request', 'lookup returned no issue record', 'lookup failed']);
|
||||||
|
assert.equal(r.lookups, LOOKUP_BUDGET);
|
||||||
|
assert.deepEqual([state(20), state(21)], [{ state: 'unknown', detail: 'over the lookup budget' }, { state: 'unknown', detail: 'over the lookup budget' }]);
|
||||||
|
// The open list is one call; a pull request in it is not an issue, so #2 is looked up.
|
||||||
|
const calls = f.calls();
|
||||||
|
assert.equal(calls.length, 1 + LOOKUP_BUDGET);
|
||||||
|
assert.match(calls[0], /^repos\/mosaicstack\/stack\/issues\?state=open&type=issues&limit=50&page=1$/);
|
||||||
|
assert.deepEqual(calls.slice(1), [2, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(n => `repos/mosaicstack/stack/issues/${n}`));
|
||||||
|
assert.equal(r.openListFull, false);
|
||||||
|
writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i))));
|
||||||
|
assert.equal(issueStates('/', [row(1, { issues: [500], closes: [500] })], null, f.tool).openListFull, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full open list: lookups settle what it leaves out, and only an unsettled issue keeps it undecided', t => {
|
||||||
|
const f = fakeTool(t);
|
||||||
|
Object.assign(process.env, f.env);
|
||||||
|
t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; });
|
||||||
|
writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i))));
|
||||||
|
const check = rows => queueChecks(queue(rows), { issues: issueStates('/', rows, null, f.tool), seats: null, now: NOW });
|
||||||
|
// Every wanted issue resolved: #600 closed by lookup, #501 open on the page for a pending row.
|
||||||
|
f.issue(600);
|
||||||
|
const resolved = check([row(1, { state: 'done', issues: [600], closes: [600] }), row(2, { issues: [501], closes: [501] })]);
|
||||||
|
assert.deepEqual([resolved.issueChecks, resolved.undecided, resolved.violations, resolved.result], [{ run: true, lookups: 1, openListFull: true }, [], [], 'reduced pass']);
|
||||||
|
assert.ok(formatQueue(resolved).includes('queue issue checks: open list (full page), 1 lookup'));
|
||||||
|
// An open issue off the page is found by lookup, and its done row fails.
|
||||||
|
f.issue(601, { state: 'open' });
|
||||||
|
const offPage = check([row(1, { state: 'done', issues: [601], closes: [601] })]);
|
||||||
|
assert.deepEqual([ids(offPage.violations), offPage.undecided, offPage.result], [[['issue-open', 1, 601]], [], 'fail']);
|
||||||
|
// Past the budget an issue stays unknown, and the full page stays undecided.
|
||||||
|
const many = Array.from({ length: LOOKUP_BUDGET + 1 }, (_, i) => 700 + i);
|
||||||
|
for (const n of many) f.issue(n);
|
||||||
|
const budget = check(many.map(n => row(n, { state: 'done', issues: [n], closes: [n] })));
|
||||||
|
assert.deepEqual(ids(budget.undecided), [['issue-unknown', 710, 710], ['open-list-full', null, null]]);
|
||||||
|
assert.match(budget.undecided[0].message, /is unknown \(over the lookup budget\)$/);
|
||||||
|
assert.equal(budget.result, 'incomplete');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the open list refuses on a failed call or a bad record, and never echoes the helper', t => {
|
||||||
|
const f = fakeTool(t);
|
||||||
|
Object.assign(process.env, f.env);
|
||||||
|
t.after(() => { for (const k of [...Object.keys(f.env), 'OPEN_FAIL', 'API_FAIL']) delete process.env[k]; });
|
||||||
|
const rows = [row(1, { issues: [1], closes: [1] })];
|
||||||
|
const refuses = (pattern) => assert.throws(() => issueStates('/', rows, null, f.tool), e => e.exitCode === 2 && pattern.test(e.message) && !/secret/.test(e.message));
|
||||||
|
process.env.API_FAIL = '3'; refuses(/credentials missing, unreadable, or invalid/); delete process.env.API_FAIL;
|
||||||
|
process.env.OPEN_FAIL = '1'; refuses(/credential or Gitea request failure/); delete process.env.OPEN_FAIL;
|
||||||
|
for (const bad of ['nope', '{}', '[{"number":0,"state":"open"}]', '[{"number":1,"state":"merged"}]', '[{"number":1,"state":"closed"}]']) {
|
||||||
|
writeFileSync(f.env.OPEN, bad);
|
||||||
|
refuses(/Open issues unavailable/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a helper call past the deadline is killed with its child, and the call reports it', t => {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-slow-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
// The helper starts a child, as gitea-api.sh starts curl, and both hang.
|
||||||
|
const tool = path.join(dir, 'gitea-api.sh');
|
||||||
|
writeFileSync(tool, `#!/usr/bin/env node
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||||
|
require('fs').appendFileSync('${dir}/pids', child.pid + '\\n');
|
||||||
|
setTimeout(() => {}, 30000);
|
||||||
|
`);
|
||||||
|
chmodSync(tool, 0o755);
|
||||||
|
const started = Date.now();
|
||||||
|
assert.throws(() => readOpenIssues('/', tool, 1), e => e.exitCode === 2 && /^Open issues unavailable: no answer within 1 s; use --no-issues/.test(e.message));
|
||||||
|
assert.deepEqual(lookupIssue('/', 5, tool, 1), { state: 'unknown', detail: 'lookup failed' });
|
||||||
|
assert.ok(Date.now() - started < 10000, 'each call returns at its deadline');
|
||||||
|
const pids = readFileSync(path.join(dir, 'pids'), 'utf8').split('\n').filter(Boolean).map(Number);
|
||||||
|
assert.equal(pids.length, 2);
|
||||||
|
// Give init a moment to reap the killed children.
|
||||||
|
const until = Date.now() + 3000;
|
||||||
|
while (pids.some(pidAlive) && Date.now() < until) spawnSync('sleep', ['0.1']);
|
||||||
|
assert.deepEqual(pids.filter(pidAlive), [], 'no child outlives the call');
|
||||||
|
assert.throws(() => readOpenIssues('/', path.join(dir, 'absent'), 1), /Open issues unavailable: gitea-api\.sh unavailable;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readQueue loads queue.json through the queue validator and refuses anything else', t => {
|
||||||
|
const repo = scratchRepo(t);
|
||||||
|
genesisCommitted(repo);
|
||||||
|
const q = readQueue(repo.root);
|
||||||
|
assert.deepEqual([q.revision, q.canonicalRoot, q.rows.map(r => r.id)], [0, repo.root, [1, 6, 8, 9, 11]]);
|
||||||
|
assert.match(q.genesisAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
const text = readFileSync(repo.queuePath, 'utf8');
|
||||||
|
writeFileSync(repo.queuePath, text.replace('"state": "briefed"', '"state": "done"'));
|
||||||
|
assert.throws(() => readQueue(repo.root), /Queue unavailable: .*hand edit/);
|
||||||
|
rmSync(repo.queuePath);
|
||||||
|
assert.throws(() => readQueue(repo.root), /missing or not a regular file; use --no-queue/);
|
||||||
|
writeFileSync(path.join(repo.base, 'elsewhere.json'), text);
|
||||||
|
symlinkSync(path.join(repo.base, 'elsewhere.json'), repo.queuePath);
|
||||||
|
assert.throws(() => readQueue(repo.root), /missing or not a regular file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('protected changes list every in-range entry that changes a required or parked row', t => {
|
||||||
|
const repo = scratchRepo(t);
|
||||||
|
genesisCommitted(repo);
|
||||||
|
// Row 9 is required, row 6 is neither required nor parked.
|
||||||
|
for (const [id, op] of [[9, 'note-row9-0001'], [6, 'note-row6-0001']]) {
|
||||||
|
const r = queueCli(repo, ['note', String(id), 'text', '--op', op], { by: 'darkwing' });
|
||||||
|
assert.equal(r.code, 0, r.err);
|
||||||
|
}
|
||||||
|
// Unparking row 8 leaves it neither parked nor required: the row before
|
||||||
|
// the entry makes it protected.
|
||||||
|
const unpark = queueCli(repo, ['move', '8', 'queued', '--op', 'move-row8-0001'], { by: 'jason' });
|
||||||
|
assert.equal(unpark.code, 0, unpark.err);
|
||||||
|
const { log } = readQueue(repo.root);
|
||||||
|
const all = protectedChanges(log, { start: 0, end: Infinity });
|
||||||
|
assert.deepEqual(all.map(c => [c.rev, c.verb, c.by, c.rows]), [[0, 'genesis', 'sage', [8, 9]], [1, 'note', 'darkwing', [9]], [3, 'move', 'jason', [8]]]);
|
||||||
|
assert.equal(all[1].op, 'note-row9-0001');
|
||||||
|
// The range includes its start and excludes its end. A range after
|
||||||
|
// genesis replays up to its first entry and starts there.
|
||||||
|
assert.ok(Date.parse(log[0].at) < Date.parse(log[1].at) && Date.parse(log[1].at) < Date.parse(log[2].at));
|
||||||
|
assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Infinity }).map(c => c.rev), [1, 3]);
|
||||||
|
assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[1].at) }).map(c => c.rev), [0]);
|
||||||
|
assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Date.parse(log[2].at) }).map(c => c.rev), [1]);
|
||||||
|
assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[0].at) }), []);
|
||||||
|
const q = queueChecks(readQueue(repo.root), { issues: null, seats: null, exempt: new Set(['darkwing']), now: NOW, range: { start: 0, end: Infinity } });
|
||||||
|
const text = formatQueue(q);
|
||||||
|
assert.ok(text.includes('protected change rev 1 note by darkwing at ' + log[1].at + ': row 9'));
|
||||||
|
assert.ok(text.includes('protected changes in range: 3 (not checks; confirm the actors)'));
|
||||||
|
assert.equal(q.result, 'incomplete', 'the list is not a finding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A scratch repository with a committed queue, the ledger and the seat code,
|
||||||
|
// a config whose dataRoot holds the registrations, and a fake helper.
|
||||||
|
const MAP = [
|
||||||
|
{ id: 1, piece: 'Closed and done', owner: 'darkwing', issues: [101], closes: [101], state: 'done', required: false, requiredSince: null, brief: null },
|
||||||
|
{ id: 2, piece: 'Done but open', owner: 'darkwing', issues: [102], closes: [102], state: 'done', required: false, requiredSince: null, brief: null },
|
||||||
|
{ id: 3, piece: 'Active and old', owner: 'dewey', issues: [103], closes: [103], state: 'in-progress', required: true, requiredSince: '2020-01-01', brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } },
|
||||||
|
{ id: 4, piece: 'Active and registered', owner: 'rocko', issues: [103], closes: [], state: 'in-progress', required: false, requiredSince: null, brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } },
|
||||||
|
].map(r => {
|
||||||
|
const full = { previousState: null, gate: 'g', gateOwner: 'jason', after: [], reviewers: [], note: null, blockedReason: null, createdAt: '2026-09-13', ...r };
|
||||||
|
// The map wants its keys in the queue's order.
|
||||||
|
return Object.fromEntries(Object.keys(MAP_ROWS[0]).map(k => [k, full[k]]));
|
||||||
|
});
|
||||||
|
function cliFixture(t, rows = MAP) {
|
||||||
|
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
||||||
|
genesisCommitted(repo);
|
||||||
|
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
||||||
|
const dataRoot = path.join(repo.base, 'data');
|
||||||
|
const config = path.join(repo.base, 'config.json');
|
||||||
|
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||||
|
const f = fakeTool(t);
|
||||||
|
f.issue(101);
|
||||||
|
writeFileSync(f.env.OPEN, JSON.stringify([openIssue(102), openIssue(103)]));
|
||||||
|
writeFileSync(f.env.ISSUES, '[]');
|
||||||
|
const seats = path.join(dataRoot, 'seats');
|
||||||
|
mkdirSync(seats, { recursive: true });
|
||||||
|
writeRegistration(seats, { version: 1, seat: 'rocko', project: 'repo', task: '', workspace: repo.root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z',
|
||||||
|
pid: process.pid, sessionsDir: path.join(repo.root, '.pi/state/rocko/sessions'), seatDir: path.join(repo.root, 'agents/rocko'), launchScript: path.join(repo.root, 'agents/rocko/launch.sh'), layout: 'repo', updatedAt: null });
|
||||||
|
const run = (args = [], env = {}, { until = '2026-09-26' } = {}) => {
|
||||||
|
writeFileSync(f.env.CALLS, '');
|
||||||
|
const r = spawnSync(process.execPath, [path.join(repo.root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-20', '--until', until, '--no-t3', ...args],
|
||||||
|
{ cwd: repo.root, encoding: 'utf8', env: { ...repo.env, PATH: `${f.dir}:${process.env.PATH}`, MOSAIC_CONFIG: config, ...f.env, ...env } });
|
||||||
|
return { ...r, calls: f.calls() };
|
||||||
|
};
|
||||||
|
return { repo, f, run, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the CLI prints the queue section above the weekly table and under a queue key in --json', t => {
|
||||||
|
const { run } = cliFixture(t);
|
||||||
|
const text = run(['--unsupported-runtime', 'dewey']);
|
||||||
|
assert.equal(text.status, 0, text.stderr);
|
||||||
|
const lines = text.stdout.split('\n');
|
||||||
|
assert.match(lines[0], /^Queue checks: queue\.json revision 0, as of \d{4}-/);
|
||||||
|
assert.ok(lines.indexOf('queue: 2 violations; result fail') < lines.findIndex(l => l.startsWith('Ledger 2026-09-20')));
|
||||||
|
assert.ok(lines.includes('violation issue-open row 2 #102: row 2 is done and every row that closes #102 is done, but #102 is open'));
|
||||||
|
assert.ok(lines.some(l => /^violation age row 3: row 3 "Active and old" is required and not done, \d+ days since 2020-01-01$/.test(l)));
|
||||||
|
assert.ok(lines.includes('owner dewey (row 3): exempt, declared with --unsupported-runtime'));
|
||||||
|
assert.ok(lines.includes('owner rocko (row 4): pid-present, pid present (identity not verified)'));
|
||||||
|
assert.ok(lines.includes('liveness: 1 pid-present (unverified), 1 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
||||||
|
assert.ok(lines.includes('queue issue checks: open list, 1 lookup'));
|
||||||
|
// Metric page, open list, one lookup for #101.
|
||||||
|
assert.deepEqual(text.calls.map(c => c.replace(/\?.*/, '?')), ['repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues/101']);
|
||||||
|
const json = JSON.parse(run(['--json', '--unsupported-runtime', 'dewey']).stdout);
|
||||||
|
assert.deepEqual([json.queue.result, json.queue.violations.map(v => [v.check, v.row, v.issue])], ['fail', [['issue-open', 2, 102], ['age', 3, null]]]);
|
||||||
|
// Genesis is dated today, outside the requested week.
|
||||||
|
assert.deepEqual(json.queue.protectedChanges, []);
|
||||||
|
const week = JSON.parse(run(['--json', '--no-issues'], {}, { until: new Date().toISOString().slice(0, 10) }).stdout).queue;
|
||||||
|
assert.deepEqual(week.protectedChanges.map(c => [c.rev, c.verb, c.rows]), [[0, 'genesis', [3]]]);
|
||||||
|
// Without the declaration, dewey has no registration.
|
||||||
|
const missing = JSON.parse(run(['--json']).stdout).queue;
|
||||||
|
assert.deepEqual(missing.violations.map(v => v.check), ['issue-open', 'owner-missing', 'age']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queue with nothing wrong prints 0 violations and a reduced pass, never a full pass', t => {
|
||||||
|
const { run } = cliFixture(t, [MAP[0], MAP[3]]);
|
||||||
|
const r = run();
|
||||||
|
assert.equal(r.status, 0, r.stderr);
|
||||||
|
const lines = r.stdout.split('\n');
|
||||||
|
assert.ok(lines.includes('queue: 0 violations; result reduced pass'));
|
||||||
|
assert.ok(lines.includes('liveness: 1 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone'));
|
||||||
|
assert.ok(!lines.some(l => /^(violation|undecided|disposition) /.test(l)));
|
||||||
|
assert.equal(r.calls.length, 3, 'metric page, open list, one lookup');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--no-issues makes no call and leaves the issue checks undecided; --no-queue skips the section', t => {
|
||||||
|
const { run } = cliFixture(t);
|
||||||
|
const none = run(['--no-issues', '--json']);
|
||||||
|
assert.equal(none.status, 0, none.stderr);
|
||||||
|
assert.deepEqual(none.calls, []);
|
||||||
|
assert.deepEqual(JSON.parse(none.stdout).queue.issueChecks, { run: false });
|
||||||
|
const skipped = run(['--no-queue']);
|
||||||
|
assert.equal(skipped.status, 0, skipped.stderr);
|
||||||
|
assert.match(skipped.stdout, /^Queue: not checked \(--no-queue\)\nLedger 2026-09-20/);
|
||||||
|
assert.equal(skipped.calls.length, 1, 'the metric call only');
|
||||||
|
assert.deepEqual(JSON.parse(run(['--no-queue', '--json']).stdout).queue, { checked: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the CLI refuses a bad queue before any call, and a failed open list with exit 2', t => {
|
||||||
|
const { repo, run } = cliFixture(t);
|
||||||
|
const refusedOpen = run([], { OPEN_FAIL: '1' });
|
||||||
|
assert.deepEqual([refusedOpen.status, refusedOpen.stdout], [2, '']);
|
||||||
|
assert.match(refusedOpen.stderr, /^Open issues unavailable: credential or Gitea request failure; use --no-issues/);
|
||||||
|
const text = readFileSync(repo.queuePath, 'utf8');
|
||||||
|
writeFileSync(repo.queuePath, text.replace('"Done but open"', '"Done, hand edited"'));
|
||||||
|
const edited = run();
|
||||||
|
assert.deepEqual([edited.status, edited.stdout, edited.calls], [1, '', []]);
|
||||||
|
assert.match(edited.stderr, /^Queue unavailable: /);
|
||||||
|
rmSync(repo.queuePath);
|
||||||
|
const gone = run();
|
||||||
|
assert.deepEqual([gone.status, gone.calls], [1, []]);
|
||||||
|
assert.match(gone.stderr, /use --no-queue to skip the queue section/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--unsupported-runtime repeats once per seat and takes a seat name', t => {
|
||||||
|
const { run } = cliFixture(t);
|
||||||
|
const two = JSON.parse(run(['--json', '--no-issues', '--unsupported-runtime', 'dewey', '--unsupported-runtime', 'rocko']).stdout).queue;
|
||||||
|
assert.deepEqual([two.liveness.exempt, two.liveness.counts.exempt], [['dewey', 'rocko'], 2]);
|
||||||
|
for (const [args, message] of [
|
||||||
|
[['--unsupported-runtime', 'dewey', '--unsupported-runtime', 'dewey'], /^Duplicate --unsupported-runtime dewey\n$/],
|
||||||
|
[['--unsupported-runtime'], /needs a seat name/],
|
||||||
|
[['--unsupported-runtime', '--json'], /needs a seat name/],
|
||||||
|
[['--unsupported-runtime', 'Dewey'], /needs a seat name/],
|
||||||
|
[['--no-queue', '--unsupported-runtime', 'dewey'], /cannot be combined/],
|
||||||
|
]) {
|
||||||
|
const r = run(args);
|
||||||
|
assert.equal(r.status, 1, args.join(' '));
|
||||||
|
assert.match(r.stderr, message);
|
||||||
|
assert.deepEqual([r.stdout, r.calls], ['', []]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreadable config makes every owner invalid instead of passing them', t => {
|
||||||
|
const { run } = cliFixture(t);
|
||||||
|
const r = JSON.parse(run(['--json', '--no-issues'], { MOSAIC_CONFIG: '/nonexistent/config.json' }).stdout).queue;
|
||||||
|
assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class]), [['dewey', 'invalid'], ['rocko', 'invalid']]);
|
||||||
|
assert.equal(r.result, 'fail');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user