From 0411428290ac79f51bec81dc6147be8718540a6e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 09:35:35 +0200 Subject: [PATCH 01/17] docs(telemetry): frame the ledger by day, with the window measured Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_ledger-by-day/contract.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md new file mode 100644 index 000000000..03101662f --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md @@ -0,0 +1,67 @@ +--- +status: in-progress +issue: 1001 +--- + +# Ledger by day: an ingest costs what it adds + +## Need + +One ingested call costs the size of the whole ledger (#1001). Measured on `next` (`0a103e67`), a synthetic month of 265,000 calls (118 MB), one new call: + +| Step | Time | Why | +| --- | --- | --- | +| `load()` | 2.8 s | reads, validates and folds every partition | +| `upsertUsage` | 2.7 s | refolds held and incoming together | +| `save(month)` | 6.0 s | stringifies 265k records, re-reads the file to compare, rewrites it | +| total | ~11.5 s | | + +## Decision + +Partition the ledger by the UTC **day** of a record's `at` (`ledger/.jsonl`) instead of its month. Everything else stays: a changed partition is rewritten whole by rename, an unchanged one is not touched, records are in canonical order, one call is one line. + +An alternative, append-only partitions compacted by `report`, was weighed and set aside. It is faster by about 0.1 s, but it needs a compaction rule, torn-line handling and a fold in every reader, and it gives up one-call-one-line between reports. + +## Evidence for the ingest window + +Ingest reads only the days of its incoming records, plus the day before and after. Whether that window is enough was measured, read-only, on a real Claude Code history of 3,621 transcripts: + +| Measure | Value | +| --- | --- | +| usage lines | 493,006 | +| distinct keys (`message.id:requestId`) | 262,631 | +| keys written more than once | 177,235 | +| largest spread between a key's first and last line | 0.3 h | +| keys whose lines fall on two UTC days | 3 | +| keys spread over one day or more | 0 | + +A key's snapshots are minutes apart at most, so the day before and after holds any earlier snapshot. The key and its `at` come from `domain/formats/claude-transcript-usage.ts:87-96`. + +## The case never observed + +A key whose snapshots were more than a day apart would be outside the window. Ingest would then write it a second time, in another day, and nothing reported would change: `load()` still folds the whole ledger, so each call counts once, by the unchanged fold rule. What would change is the bytes on disk, which would then depend on the order of ingests. + +**`report` therefore restores them.** It already loads every partition. When it finds one call in two partitions, it rewrites the partitions involved, folded. After any report, the ledger is byte-identical to the one a single ingest of the same calls would have written. + +## Contract + +- **Format.** `ledger/.jsonl`, one `StoredUsage` per line, by the UTC day of `at`, in time then key order. A record whose `at` does not parse is left out, as today. +- **Ingest.** It loads the partitions of its records' days and the days on each side, folds only those with the incoming records, and rewrites only the days that changed. Its cost does not grow with the ledger's size. +- **Report.** It reads every partition, monthly and daily, and folds them. When a call sits in more than one partition, or a monthly partition remains, it rewrites what it read in canonical daily form, under the ledger lock. +- **Migration.** A monthly `.jsonl` is still read. The first ingest whose window touches a month splits it into its days, then removes it. + - A crash between the two leaves each call in both files. Fold-at-read counts it once, and the next ingest or report finishes the split. +- **Damage.** A line that is not a record is still skipped, counted, and dropped when its partition is rewritten, whichever format it sits in. +- **`forget`** removes daily and monthly partitions alike. `offsets.json` is unchanged. +- **Unchanged.** The fold rule, the `added` and `updated` counts within the window, every report axis and the report envelope. +- **Out of scope.** Report cost: `load()` still reads the whole ledger, 2.8 s at this size. It is a separate issue. + +## Acceptance + +| # | Criterion | Proof | +| --- | --- | --- | +| 1 | One call into a 265k-call month ingests in under 1 s | the same bench, before and after | +| 2 | One set of calls gives byte-identical partitions whatever the order and grouping of its ingests, starting from monthly files or not, once a report has run | fast-check property | +| 3 | No report total changes across the migration or across a crash at any point of it | tests | +| 4 | The fold rule is unchanged, and every axis adds up to `totals` | existing suite, plus `scripts/check-claude-tokens-end-to-end.cjs` in both modes | +| 5 | Every reader of the partitions is updated, and the contract and code map say "day" | grep, plus a fresh checker | +| 6 | Tests are written first and seen to fail; telemetry mutation stays at 96 or above | mutation run | From 8e48695050b12aca1c28042542ab075f5910601d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 09:45:43 +0200 Subject: [PATCH 02/17] docs(telemetry): plan the ledger by day in five phases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_ledger-by-day/contract.md | 2 +- .../2026_10_15_ledger-by-day/phase-1.md | 85 +++++++++++++ .../2026_10_15_ledger-by-day/phase-2.md | 117 ++++++++++++++++++ .../2026_10_15_ledger-by-day/phase-3.md | 89 +++++++++++++ .../2026_10_15_ledger-by-day/phase-4.md | 79 ++++++++++++ .../2026_10_15_ledger-by-day/phase-5.md | 80 ++++++++++++ .../2026_10/2026_10_15_ledger-by-day/plan.md | 39 ++++++ 7 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md index 03101662f..c113ba4e9 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md @@ -47,7 +47,7 @@ A key whose snapshots were more than a day apart would be outside the window. In - **Format.** `ledger/.jsonl`, one `StoredUsage` per line, by the UTC day of `at`, in time then key order. A record whose `at` does not parse is left out, as today. - **Ingest.** It loads the partitions of its records' days and the days on each side, folds only those with the incoming records, and rewrites only the days that changed. Its cost does not grow with the ledger's size. -- **Report.** It reads every partition, monthly and daily, and folds them. When a call sits in more than one partition, or a monthly partition remains, it rewrites what it read in canonical daily form, under the ledger lock. +- **Report.** It reads every partition, monthly and daily, and folds them. When a call sits in more than one partition, a monthly partition remains, or a partition holds a line that is not a record, it rewrites those partitions in canonical daily form, under the ledger lock. - **Migration.** A monthly `.jsonl` is still read. The first ingest whose window touches a month splits it into its days, then removes it. - A crash between the two leaves each call in both files. Fold-at-read counts it once, and the next ingest or report finishes the split. - **Damage.** A line that is not a record is still skipped, counted, and dropped when its partition is rewritten, whichever format it sits in. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md new file mode 100644 index 000000000..91837c865 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md @@ -0,0 +1,85 @@ +--- +status: pending +--- + + + +# Instruction: A record belongs to a day, an ingest to a window + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/domain/ +│ └── stored-usage.ts ✏️ add dayOf, partitionByDay, daysAround (monthOf, partitionByMonth stay until phase 2) +└── tests/contexts/telemetry/domain/ + └── stored-usage.unit.test.ts ✏️ day, partition-by-day and window cases, written first +``` + +## User Journey + +```mermaid +flowchart TD + A[an incoming call with its at] --> B[dayOf => YYYY-MM-DD in UTC] + B --> C[daysAround => the day, the day before, the day after] + C --> D[the window an ingest will read in phase 2] + B --> E[partitionByDay => canonical bytes of a day] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + build records with known at values across a month and a year boundary => records ready: 5: system + section Happy path + dayOf of an instant with an offset => the UTC day: 5: system + daysAround of calls on two days => each day plus its neighbours, once each: 5: system + partitionByDay of shuffled records => days in order, each in time then key order: 5: system + section Edge case - unparseable time + a record whose at does not parse => dayOf and daysAround and partitionByDay => no day, left out: 1: system + section Edge case - boundaries + a call at 2026-03-01T00:30Z => daysAround => includes 2026-02-28: 1: system + a call at 2026-12-31T23:59Z => daysAround => includes 2027-01-01: 1: system +``` + +## Tasks to do + +### `1)` Measure "before" + +> Record the baseline criterion 1 is judged against, on unchanged code. + +1. From the repository root, run the bench as it stands (command in `phase-5.md`, task 1) and keep its per-step timings (`load() total`, `upsert one`, `save(month)`) for the phase 5 comparison. The contract's table (~11.5 s) is the reference if the run differs by machine. + +### `2)` Tests first + +> Each new test fails because the function does not exist or returns the month. + +1. In `stored-usage.unit.test.ts`, a `describe("a record belongs to the day of its own time, in UTC")`: reads the day from the instant; uses UTC at the day boundary whatever the offset (`2026-10-07T23:30:00-02:00` => `2026-10-08`); pads month and day; `null` for a time that is not one. Must fail with "dayOf is not exported". +2. A `describe("an ingest reads its calls' days and the day on each side")` for `daysAround(records)`: one call => three days; two calls on adjacent days => four days, no duplicate; month boundary (`2026-03-01` => `2026-02-28`) and year boundary (`2026-12-31` => `2027-01-01`); an unparseable `at` contributes nothing; empty input => empty set. Must fail with "daysAround is not exported". +3. A `describe("a partition is the day's records in a fixed order")` mirroring the month block (lines ~120-176): groups by day and orders by time then key; lists days in order whatever the input order; two records of one instant ordered by key; a record with no usable time in no partition; the linear-copy case kept. Must fail with "partitionByDay is not exported". +4. Run `pnpm vitest run tests/contexts/telemetry/domain/stored-usage.unit.test.ts` and see exactly those failures. + +### `3)` Implement + +> Pure transforms, beside the month ones they will replace. + +1. `dayOf(at)`: `YYYY-MM-DD` of `Date.parse(at)` in UTC, `null` when it does not parse. Doc comment says why UTC (partition independent of the machine's zone). +2. `partitionByDay(records)`: same algorithm as `partitionByMonth` (parse once, sort by instant then `tool\0key` with `compareText`, push by key, keys sorted), keyed by `dayOf`. +3. `daysAround(records)`: for each record with a day, add the day and its UTC neighbours (`Date.parse(day + "T00:00:00.000Z") ± 86_400_000`, sliced back to a day). Doc comment cites the contract's evidence: a call's snapshots are minutes apart, so a neighbour day holds any earlier one. +4. Re-run the file: green. `pnpm typecheck` green (old functions still used, nothing removed yet). + +## Test acceptance criteria + + + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | A "before" timing for one call into a 265k-call month is recorded, for contract #1 | +| 2 | Each new test was seen failing for the missing export, before any implementation (contract #6) | +| 3 | A call's day is its UTC day; a window holds each call's day and both neighbours across month and year boundaries; a day's records come out in time then key order whatever the input order (contract Format) | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md new file mode 100644 index 000000000..f9b6d5fb3 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md @@ -0,0 +1,117 @@ +--- +status: pending +--- + + + +# Instruction: The ledger on disk, by day, read by window + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/ +│ ├── domain/ +│ │ ├── stored-usage.ts ✏️ upsertUsage returns days; monthOf and partitionByMonth removed +│ │ └── ports/usage-ledger.ts ✏️ load(days?), save(records, partitions?), LoadedLedger.damaged + unsettled +│ ├── infrastructure/usage-ledger-adapter.ts ✏️ daily files, windowed read, monthly split and retirement +│ └── application/ +│ ├── ledger-upkeep.ts ✅ storeCalls(ledger, incoming) +│ └── ingest-usage-use-case.ts ✏️ calls storeCalls; undated check uses dayOf +└── tests/ + ├── helpers/ports/in-memory-telemetry.ts ✏️ InMemoryLedger follows the new port + └── contexts/telemetry/ + ├── domain/stored-usage.unit.test.ts ✏️ month cases removed, upsert names days + ├── infrastructure/usage-ledger-adapter.integration.test.ts ✏️ day format, window, migration, damage + └── application/ingest-usage-use-case.unit.test.ts ✏️ window and saved partitions +``` + +## User Journey + +```mermaid +flowchart TD + A[ingest has calls to store] --> B[storeCalls => window = daysAround] + B --> C[load window => its days, plus every touched month's monthly file and daily files] + C --> D[upsertUsage folds window with incoming => changed days] + D --> E{changed, damaged or unsettled?} + E -- no --> F[nothing written] + E -- yes --> G[save changed days + damaged + unsettled] + G --> H[each named day rewritten by rename if its bytes differ] + G --> I[each named month => all its days written, then its monthly file removed] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + temp ledger dir with PrivateStorageAdapter => empty ledger: 5: system + section Happy path + save records over three days => three YYYY-MM-DD.jsonl files in canonical bytes: 5: system + load a window of one day => only that day's records, no other file opened: 5: system + ingest one call beside a 2-day-old ledger => only the call's day rewritten, other inodes unchanged: 5: system + section Edge case - earlier snapshot the day before + held call on D-1, better snapshot on D => storeCalls => one copy, on D, D-1 rewritten without it: 1: system + section Edge case - monthly file in the window + 2026-09.jsonl present => ingest a call on 2026-09-10 => every September day written, 2026-09.jsonl removed: 1: system + section Edge case - half-finished split + monthly file and some of its days present with a better snapshot in a day => ingest in that month => the better snapshot kept, monthly removed: 1: system + section Edge case - damaged line + a bad line in a daily file in the window => ingest => counted, dropped on rewrite: 1: system + a bad line in a monthly file in the window => ingest => counted, dropped when the month is split: 1: system + section Teardown + remove the temp dir => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Tests first, adapter + +> Rewrite `usage-ledger-adapter.integration.test.ts` around days and windows; every new case fails on the monthly adapter. + +1. Turn every month case into its day twin (`files each record under the day of its own time, in UTC`, `rewrites only the days it is told changed, and reads no other partition`, `removes a told day that no longer holds anything`, `leaves one live record when a better snapshot moves a call to another day`, `reads only partition files, and leaves the offsets, roots, consents and lock beside them alone`, `names only the partitions that hold a line that is not a record`). Fails: files are named `YYYY-MM.jsonl`. +2. Window read: `load(new Set(["2026-10-07"]))` returns the records of that day only, and does not open another day (prove it as the existing "reads no other partition" case does: a neighbour file holding a bad line is neither counted nor rewritten). Fails: `load` takes no argument and reads everything. +3. Monthly read: a hand-written `2026-09.jsonl` (canonical month bytes built in the test, not with a removed helper) is read by `load()` and by `load(new Set(["2026-09-10"]))`; both list `2026-09` in `unsettled`. Fails: `unsettled` does not exist. +4. Month touched by the day before: `load(new Set(["2026-10-01", "2026-09-30"]))` with only `2026-09.jsonl` present reads it. +5. Split: `save(records, new Set(["2026-09"]))` writes each September day that has records, removes `2026-09.jsonl`, and the bytes equal those of `save(records)` into an empty dir. Removal happens after the days: with a `PrivateStorage` whose `replace` throws on the n-th call, the monthly file is still there. +6. Half-finished split: `2026-09.jsonl` plus `2026-09-10.jsonl` holding a better snapshot of a call the month also holds; `load(new Set(["2026-09-20"]))` returns the better snapshot and lists both partitions in `unsettled` (same call in two partitions); saving `unsettled` keeps it and removes the monthly file. +7. Duplicate across days: the same key in `2026-10-06.jsonl` and `2026-10-07.jsonl` => full `load()` lists both in `unsettled`, the folded record once. +8. Damage in both formats: a bad line in a daily and in a monthly file is skipped, counted in `skippedLines`, named in `damaged`, and gone once that partition is saved (for the monthly one, once its month is split). +9. Run the file: every new case red for the reason it names. + +### `2)` Tests first, domain and use case + +> The fold reports days; ingest reads its window and saves only what it must. + +1. `stored-usage.unit.test.ts`: delete the month `describe` blocks (lines ~32-50 and ~120-176, now covered by phase 1's day twins); `names the days it changed: where a record is now, and where a replaced one was` replaces the month case (line ~53). Fails: `months` is returned, not `days`. +2. `ingest-usage-use-case.unit.test.ts` (lines ~320-353): `tells the ledger to load only its calls' days and the day on each side` (asserts the set `InMemoryLedger` was loaded with); `tells the ledger to rewrite only the day a new call falls in`; `also rewrites a damaged partition its window read, whatever the call that came`; `repairs damage in its window even when every call it read was already held`; `finishes a monthly partition its window read even when every call was already held`; `loads nothing when no call is to be stored` (kept behaviour). Fails: `load` called without a window, `savedMonths` holds months. +3. `InMemoryLedger` follows the port: `load(days?)` records `loadedDays` and returns the records whose `dayOf` is in the days (all without), `damaged` and `unsettled` are settable sets, `save` records `savedPartitions`. + +### `3)` Implement + +> Port first, then domain, adapter, use case; the doc comments state the rules, not the history. + +1. `ports/usage-ledger.ts`: `LoadedLedger { records; skippedLines; damaged; unsettled }` and `load(days?)`, `save(records, partitions?)`, each doc'd exactly as `plan.md` Decisions rows 3-5 state, keeping "the caller vouches that the rest is as it was". +2. `stored-usage.ts`: `upsertUsage` returns `days` (via `dayOf`) instead of `months`; remove `monthOf` and `partitionByMonth` (knip would flag them). +3. `usage-ledger-adapter.ts`: `DAILY` and `MONTHLY` regexes; one `readdir` per call; `load(days?)` per Decision row 3, collecting `damaged` and `unsettled` (a `Map` filled while parsing; a second partition for the same key adds both); `save` per Decision row 5, writing with `storage.replace` only when the bytes differ, removing an emptied day, and removing each named monthly file after all its days; class doc rewritten for days and the window (drop "rewrite stays cheap": the window is why). +4. `application/ledger-upkeep.ts`: `storeCalls(ledger, incoming)` => `{ added, updated, skippedLines }`: `load(daysAround(incoming))`, `upsertUsage`, and `save(merged.records, days ∪ damaged ∪ unsettled)` when `added + updated > 0` or `damaged` or `unsettled` is not empty. Runs under the caller's lock. +5. `ingest-usage-use-case.ts`: lines ~116-130 become one `storeCalls` call; `outcomeOf` checks `dayOf(record.at) === null` and its comment says "no day to be kept in". +6. Re-run the three test files: green. `pnpm typecheck`, `pnpm lint` green. + +## Test acceptance criteria + + + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | Every adapter case was seen red on the monthly adapter before the change (contract #6) | +| 1, 3 | A saved ledger is one `YYYY-MM-DD.jsonl` per UTC day, canonical order, one call per line; an unchanged day keeps its inode (contract Format) | +| 2, 3 | Ingest reads only its calls' days, the day on each side and any monthly file those days fall in, and writes only changed, damaged or unsettled partitions: its work does not depend on how many other days the ledger holds (contract Ingest, #1) | +| 1, 3 | A monthly file touched by a window is split into its days, then removed; after a crash between the two, the next ingest keeps the better snapshot of each call and finishes the split (contract Migration, #3) | +| 1, 3 | A line that is not a record is skipped, counted and dropped when its daily or monthly partition is rewritten (contract Damage) | +| 2 | `added` and `updated` count the calls new or replaced within the window, as before (contract Unchanged) | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md new file mode 100644 index 000000000..930a1d793 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md @@ -0,0 +1,89 @@ +--- +status: pending +--- + + + +# Instruction: Report settles the ledger, proven by property + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/application/ +│ ├── ledger-upkeep.ts ✏️ add settleLedger(ledger) +│ └── report/report-usage-use-case.ts ✏️ exclusively(() => settleLedger(ledger)) +└── tests/contexts/telemetry/application/ + ├── ledger-upkeep.property.integration.test.ts ✅ fast-check, byte identity (contract #2) + ├── ledger-upkeep.integration.test.ts ✅ migration and crash states, totals unchanged (contract #3) + └── report/report-usage-use-case.unit.test.ts ✏️ settles under the lock, writes nothing when settled +``` + +## User Journey + +```mermaid +flowchart TD + A[report] --> B[ingest as before] + B --> C[exclusively => settleLedger] + C --> D[load every partition, daily and monthly] + D --> E{unsettled empty?} + E -- yes --> F[nothing written] + E -- no --> G[save the full fold to the unsettled partitions only] + G --> H[ledger bytes = a single ingest of the same calls] + F --> I[report built from the folded records] + H --> I +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + temp ledger dir, generated calls with snapshot variants => ready: 5: system + section Happy path + calls ingested in any grouping and order, then settleLedger => bytes equal one storeCalls of all calls into an empty dir: 5: system + a settled ledger => report => no file written, inodes unchanged: 5: system + section Edge case - snapshots more than a day apart + one call's two snapshots ingested a week apart => settleLedger => one copy, the winner, in its own day: 1: system + section Edge case - starting from monthly files + part of the calls pre-written as YYYY-MM.jsonl => ingests then settleLedger => no monthly file left, same bytes: 1: system + section Edge case - crash during the split + monthly file plus none, some or all of its days => report totals => equal to the totals before migration: 1: system + section Teardown + remove the temp dirs => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Tests first + +> Each test fails because `settleLedger` does not exist or the report never writes. + +1. `report-usage-use-case.unit.test.ts`: `settles a call held in two partitions under the ledger lock` (with `InMemoryLedger.unsettled` set: events are `lock, load, save, unlock` and `savedPartitions` equals the unsettled set); `writes nothing when the ledger is settled`; `repairs a damaged partition outside any ingest window` (plan Decisions last row: savedPartitions holds the damaged one, and the next load reports 0 skipped lines). Fails: the report only loads. +2. `ledger-upkeep.property.integration.test.ts` (fast-check, `UsageLedgerAdapter` + `PrivateStorageAdapter` on a temp dir per run): generate a small key pool (so keys collide) and per key 1-3 snapshots with different counters; `at` within minutes of each other, plus a rarer variant days apart (the case the contract never observed); dates in years 2025-2027 spanning month and year ends (4-digit years only: `toISOString` writes `+275760-...` past 9999, which no partition regex reads, an existing limit out of scope). Partition the calls into 1-5 batches in any order, optionally pre-write a random subset as canonical monthly files (built in the test from `JSON.stringify` grouped by `at.slice(0, 7)` after `foldUsage`). Run `storeCalls` per batch, then `settleLedger`. Assert the sorted file names and every file's bytes equal those of `storeCalls(allCalls)` into an empty dir followed by `settleLedger`. No damaged lines generated. Fails: `settleLedger` is not exported. +3. `ledger-upkeep.integration.test.ts`: a ledger of calls over September and October as monthly files; for each disk state of a split (monthly only; monthly plus some of its days; monthly plus all of its days; days only), the report totals, from `buildReport` over `load().records` on the `total` axis with no declarations, equal the totals before any migration; then `settleLedger` (and, separately, one `storeCalls` whose window touches the month) leaves the days-only bytes. Fails on `settleLedger`. +4. Run the three files and see each failure. + +### `2)` Implement + +> One function, one call site. + +1. `ledger-upkeep.ts`: `settleLedger(ledger)` => the `LoadedLedger`: `load()`, then `save(held.records, held.unsettled)` when `unsettled` is not empty. Doc comment: after it, the ledger is byte-identical to a single ingest of the same calls; it rewrites only the partitions involved; runs under the caller's lock. +2. `report-usage-use-case.ts` line ~59: `this.ledger.exclusively(() => settleLedger(this.ledger))`. Class doc gains one sentence: the report also leaves the ledger settled. +3. Re-run: green. If the property finds a counterexample, fix the code, never narrow the generator past the contract. + +## Test acceptance criteria + + + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | The three files were seen red before `settleLedger` existed (contract #6) | +| 1, 2 | One set of calls gives byte-identical partitions whatever the order and grouping of its ingests, from monthly files or not, once a report has run (contract #2) | +| 1, 2 | No report total changes across the migration or a crash at any point of it, and the next ingest or report finishes the split (contract #3, Migration) | +| 1, 2 | A report rewrites only partitions holding a call another one holds, or monthly ones, under the ledger lock, and writes nothing on a settled ledger (contract Report) | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md new file mode 100644 index 000000000..2e3dc5320 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md @@ -0,0 +1,79 @@ +--- +status: pending +--- + + + +# Instruction: Every reader and every page says "day" + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── aidd_docs/product/usage-contract.md ✏️ Stores row for ledger/.jsonl and the monthly migration +├── cli/ +│ ├── aidd_docs/memory/telemetry.md ✏️ "Where to look" row for the ledger's layout and the ingest window +│ ├── src/contexts/telemetry/domain/legacy/legacy-locations.ts ✏️ DAY_FILE doc no longer claims the ledger never uses the name +│ └── tests/contexts/telemetry/ +│ ├── domain/legacy/legacy-locations.unit.test.ts ✏️ "is a date, not a ledger month" renamed +│ └── infrastructure/forget/measurement-erasure-adapter.integration.test.ts ✏️ ledger daily and monthly files go as the ledger +└── plugins/aidd-telemetry/skills/01-usage/actions/frame.md (no change: grep finds no partition wording) +``` + +## User Journey + +```mermaid +flowchart TD + A[grep month across src, tests, hooks, scripts, docs] --> B{hit about ledger partitions?} + B -- yes --> C[reworded to day or removed] + B -- no --> D[left alone, e.g. period.ts, fixtures, frame.md] + C --> E[forget removes ledger/ whole, daily and monthly alike] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + telemetry dir with ledger/2026-10-07.jsonl, ledger/2026-09.jsonl and a previous sink day file => ready: 5: system + section Happy path + forget inventory => one ledger entry counting both ledger files, previous-day-file only for the sink file: 5: system + forget erase => ledger dir and sink day file gone, offsets.json gone with the ledger: 5: system + section Edge case - grep + grep month and YYYY-MM across cli/src, cli/tests, plugins, scripts, docs => no ledger partition named by month outside history and the migration reader: 1: system + section Teardown + remove the temp dirs => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Forget, both formats + +> `forget` needs no code: `MeasurementErasureAdapter` removes `ledger/` recursively, and `DAY_FILE` only scans the legacy sink directories, never `ledger/`. + +1. In `measurement-erasure-adapter.integration.test.ts`, add `removes the ledger's daily and monthly partitions as the ledger, never as an earlier day file`: inventory has one `ledger` entry with `files` counting both, no `previous-day-file` for them; `erase` leaves no `ledger/`. This test pins existing behaviour, so it cannot be seen red on today's code; prove it bites by the mutation of pointing the `DAY_FILE` scan at `ledger/` too, and watch this test (not another) fail. Note the residual risk in the PR: a person setting `AIDD_TELEMETRY_DIR` to the `ledger/` directory itself would see ledger days listed as earlier day files. + +### `2)` Wording, from the grep + +> Contract #5: every reader updated, and the contract and code map say "day". + +1. Grep evidence (re-run before editing): `grep -rnIiE "YYYY-MM[^-]||by month|monthOf|partitionByMonth|damagedMonths|savedMonths" . --exclude-dir={node_modules,.git,fixtures,dist}` and `grep -rnE 'ledger|\\d\{4\}' plugins/aidd-telemetry/hooks scripts`. At planning time the only non-history hits were the files of phases 1-3 plus `aidd_docs/product/usage-contract.md:114`; hooks and scripts read no partition (`catch-up.cjs` only calls the CLI); `frame.md` and `cli/src/presentation` mention no month partition, so no help golden changes. +2. `usage-contract.md` Stores table, line ~114: `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; `report` rewrites the partitions that share a call. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest whose window touches it or by a report, then removed. +3. `cli/aidd_docs/memory/telemetry.md` "Where to look": add `How the ledger lies on disk, and what an ingest reads` => `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts`. +4. `legacy-locations.ts` `DAY_FILE` doc: "A day file's name in the directories the previous version wrote to. The ledger names its days the same way, inside `ledger/`, which is never one of them." Rename its test to `is a date, not a month`. +5. Re-run the grep: every remaining hit is history (`aidd_docs/tasks/`), the migration reader (`MONTHLY` in the adapter and its tests), or a fixture. + +## Test acceptance criteria + + + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | `forget` removes daily and monthly ledger partitions alike, as the ledger, with `offsets.json` in the same format as today (contract forget) | +| 1 | The forget guard fails under its named mutation (contract #6) | +| 2 | No reader, page or code map names a ledger partition by month, except the monthly reader kept for migration (contract #5) | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md new file mode 100644 index 000000000..3e8ca9683 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md @@ -0,0 +1,80 @@ +--- +status: pending +--- + + + +# Instruction: Bench and the full gate + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── (no repository file; the bench lives in the session scratchpad and is not committed) +``` + +## User Journey + +```mermaid +flowchart TD + A[bench seeds 265k calls as daily files] --> B[time load window + upsert + save for one call] + B --> C{under 1 s?} + C -- yes --> D[gate commands, in order] + C -- no --> E[profile the step that dominates, fix in phase 2 code] + D --> F[mutation telemetry >= 96, claude tokens end to end both modes] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + seed 265000 calls of October 2026 as daily files in a temp dir => ledger ready: 5: cli + section Happy path + one new call => window load, upsert, save => total under 1000 ms: 5: cli + pnpm test and the telemetry mutation run => green, score 96 or more: 5: cli + end to end checker, plain and attended => exit 0: 5: cli + section Edge case - first ingest after upgrade + same 265000 calls as one 2026-10.jsonl => one new call => split timed and reported apart: 1: cli + section Teardown + remove the bench temp dir => baseline restored: 5: cli +``` + +## Tasks to do + +### `1)` Bench (contract #1) + +> Same machine, same stub storage, same record shape as the "before" run of phase 1. + +1. Adapt `bench.ts` in `/private/tmp/claude-501/-Users-baptistelafourcade-orca-workspaces-framework-brainstorm-telemetry/2c21d903-3a7e-47ac-83f8-d8b7ae3aa579/scratchpad/bench/`: seed with `ledger.save(allRecords)` (daily files), then time `storeCalls(ledger, [fresh])` and, separately, its three steps (`load(daysAround([fresh]))`, `upsertUsage`, `save`). A second section seeds the old `2026-10.jsonl` and times the same one call, labelled "one-time split". +2. Build and run, from the repository root: + `B=/private/tmp/claude-501/-Users-baptistelafourcade-orca-workspaces-framework-brainstorm-telemetry/2c21d903-3a7e-47ac-83f8-d8b7ae3aa579/scratchpad/bench; cli/node_modules/.pnpm/esbuild@0.27.3/node_modules/esbuild/bin/esbuild $B/bench.ts --bundle --platform=node --format=esm --outfile=$B/bench.mjs && node $B/bench.mjs 265000` +3. Report before (phase 1) and after side by side. A 3-day window at this density is ~29k records, roughly 0.8 s by linear scaling of the contract's table: if it lands over 1 s, find the dominating step (likely the compare-read in `save` or the full `foldUsage` sort) and fix it in the phase 2 code, not in the bench. + +### `2)` Gate, in order + +> `cli/aidd_docs/memory/coding-assertions.md`, then the repository's own hooks. + +1. From `cli/`: `pnpm lint`, `pnpm test:arch` (folder size: `application/` at 10 direct files), `pnpm typecheck`. +2. From the repository root: `node scripts/check-cli-type-honesty.mjs`. +3. From `cli/`: `pnpm knip` (no leftover `monthOf`, `partitionByMonth`), `pnpm test`. +4. From `cli/`: `pnpm test:mutation:telemetry`; the score must be 96 or more (`break: 96` in `cli/mutation-scopes.json`). Survivors in `usage-ledger-adapter.ts`, `stored-usage.ts` or `ledger-upkeep.ts` get a test, written red first. +5. From `cli/`: `pnpm build` (the checker defaults to `cli/dist/cli.js`; a stale build tests old code). Then from the root: `node scripts/check-claude-tokens-end-to-end.cjs` and `node scripts/check-claude-tokens-end-to-end.cjs --attended`, both exit 0. +6. From the root: `pnpm exec lefthook run pre-commit`. +7. A fresh checker (`aidd-dev:checker`) reads the diff against `contract.md` rows 1-6. + +## Test acceptance criteria + + + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | One call into a 265k-call month ingests in under 1 s, before and after shown from the same bench (contract #1) | +| 2 | The fold rule is unchanged: the existing suite passes and the end-to-end checker reports exactly what the fake API billed in both modes, every axis adding up to `totals` (contract #4) | +| 2 | Telemetry mutation score is 96 or more (contract #6) | +| 2 | A fresh checker finds no partition reader or page left on months (contract #5) | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md new file mode 100644 index 000000000..912e9b590 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md @@ -0,0 +1,39 @@ +--- +objective: "One ingested call costs the days it touches, not the whole ledger: the ledger is partitioned by UTC day, monthly partitions migrate, and a report leaves the bytes canonical." +status: pending +--- + + + +# Plan: Ledger by day + +## Overview + +| Field | Value | +| ---------- | ----------------------- | +| **Goal** | Partition `ledger/` by the UTC day of `at`, read and write only an ingest's window, migrate monthly files, and let `report` restore canonical bytes (#1001) | +| **Source** | [`contract.md`](./contract.md) (issue #1001). Every behaviour below is the contract's; this plan only decides the shape of the code | + +## Phases + +| # | Phase | File | +| --- | -------------------------------------------------- | ---------------------------- | +| 1 | A record belongs to a day, an ingest to a window | [`phase-1.md`](./phase-1.md) | +| 2 | The ledger on disk, by day, read by window | [`phase-2.md`](./phase-2.md) | +| 3 | Report settles the ledger, proven by property | [`phase-3.md`](./phase-3.md) | +| 4 | Every reader and every page says "day" | [`phase-4.md`](./phase-4.md) | +| 5 | Bench and the full gate | [`phase-5.md`](./phase-5.md) | + +## Decisions + +| Decision | Why | +| ---------- | ----- | +| A partition is named by its key: `YYYY-MM-DD` (daily, canonical) or `YYYY-MM` (monthly, legacy). The adapter recognises `^\d{4}-\d{2}-\d{2}\.jsonl$` and `^\d{4}-\d{2}\.jsonl$` with its own regexes, never `DAY_FILE` from `domain/legacy/` | One name space for both formats keeps the port to one set type; the legacy module describes another directory and must not become a dependency of the ledger | +| Canonical day file: the records whose `dayOf(at)` is that day, sorted by `Date.parse(at)` then `` `${tool}\u0000${key}` `` with `compareText`, one `JSON.stringify` per line, `\n`-joined, trailing `\n`. `dayOf` is `new Date(Date.parse(at)).toISOString().slice(0, 10)`, `null` when `at` does not parse | Same order and bytes rule as today's month file, so only the grouping key changes | +| Port `UsageLedger.load(days?)`: no argument reads every partition, daily and monthly. Given days, it reads `.jsonl` for each `d`, and for every month `m` that one of those days falls in and that still has `.jsonl`, it reads that monthly file and every `-DD.jsonl` | A half-finished split (crash) leaves daily files that may already hold a better snapshot than the monthly one; writing a day from the monthly copy alone would lose it. Reading the whole touched month is what makes "the next ingest finishes the split" safe | +| `LoadedLedger` = `records` (folded), `skippedLines`, `damaged` (partition keys holding a line that is not a record, was `damagedMonths`) and `unsettled` (partition keys read that are not canonical daily form: every monthly one read, and every one holding a `(tool, key)` that another partition read also holds) | `damaged` keeps today's repair-on-ingest; `unsettled` is what `report` settles, with `damaged`; ingest also settles what its window read. The two stay separate fields because they are reported differently (`skippedLines` names damage) | +| Port `save(records, partitions?)`: no argument makes the ledger hold exactly `records` (every day written, every other daily or monthly partition removed). Given keys, a day key writes that day from `records` (or removes it once empty); a month key writes every day of that month that `records` hold or that has a daily file, then removes `.jsonl`, last. No other partition is read; the caller vouches it loaded every record of each day it names | Keeps today's "save told which partitions changed reads and writes only those" contract, and the doc line "the caller vouches that the rest is as it was". Monthly removal after its days is the contract's crash order: a crash leaves each call in both files, folded once at read | +| Ingest window = `daysAround(incoming)`: each incoming record's day, the day before and the day after (UTC calendar arithmetic). `upsertUsage(held, incoming)` folds only the window with the incoming records and returns `days` (was `months`): the days of each added or replaced record and of the record it replaced | Contract "Evidence for the ingest window"; O(touched): nothing outside the window is read, folded or written | +| Report settles inside its existing `exclusively`: full `load()`, then `save(records, unsettled)` only when `unsettled` is not empty. It rewrites "the partitions involved" (contract, The case never observed), which is the narrowest reading of "it rewrites what it read in canonical daily form" (contract, Report) that reaches the same bytes | Rewriting only the involved partitions is byte-identical to rewriting all, and leaves untouched days untouched (inode unchanged), as the format rule requires | +| The storing step and the settling step are two functions, `storeCalls(ledger, incoming)` and `settleLedger(ledger)`, in a new `application/ledger-upkeep.ts`, called by `IngestUsageUseCase` and `ReportUsageUseCase` | The fast-check property (contract #2) must drive the real code, not a copy of it; driving both use cases would need the whole consent and resolver fixture. `application/` goes from 9 to 10 direct files, at the folder-size limit, not over | +| `report` rewrites every damaged or unsettled partition it read; ingest repairs damage inside its window | Contract "Report" (amended by the orchestrator): otherwise damage outside every later window would stay counted forever, which the user bar (no grey zone) rules out. The property test generates no damaged lines; an example test covers damage outside the window | From ec51d5544290daed2745cb959eee47c23dac8538 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 09:47:39 +0200 Subject: [PATCH 03/17] refactor(telemetry): a record belongs to a utc day, an ingest to a window Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_ledger-by-day/phase-1.md | 2 +- .../2026_10/2026_10_15_ledger-by-day/plan.md | 2 +- .../contexts/telemetry/domain/stored-usage.ts | 54 +++++++-- .../domain/stored-usage.unit.test.ts | 109 ++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md index 91837c865..051f2b94e 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-1.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md index 912e9b590..be2d6d513 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md @@ -1,6 +1,6 @@ --- objective: "One ingested call costs the days it touches, not the whole ledger: the ledger is partitioned by UTC day, monthly partitions migrate, and a report leaves the bytes canonical." -status: pending +status: in-progress --- diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index 08219f6f8..e98879a5e 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -26,6 +26,29 @@ export function monthOf(at: string): string | null { return Number.isNaN(instant) ? null : new Date(instant).toISOString().slice(0, 7); } +/** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone. */ +export function dayOf(at: string): string | null { + const instant = Date.parse(at); + return Number.isNaN(instant) ? null : new Date(instant).toISOString().slice(0, 10); +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The days an ingest of these records reads: each record's day and the day on each side. A + * call's snapshots are written minutes apart, so a neighbour day holds any earlier one. */ +export function daysAround(records: readonly UsageRecord[]): ReadonlySet { + const days = new Set(); + for (const record of records) { + const day = dayOf(record.at); + if (day === null) continue; + const midnight = Date.parse(`${day}T00:00:00.000Z`); + for (const instant of [midnight - DAY_MS, midnight, midnight + DAY_MS]) { + days.add(new Date(instant).toISOString().slice(0, 10)); + } + } + return days; +} + function id(record: UsageRecord): string { return `${record.tool}\u0000${record.key}`; } @@ -65,29 +88,42 @@ export function upsertUsage( return { records, added, updated, months }; } -/** Records grouped by the month of their own time, each month in time order then key, so the +/** Records grouped by the day of their own time, each day in time order then key, so the * bytes of a partition depend on what it holds and never on the order it was found in. A - * record whose time does not parse has no month and is left out. Linear in the records, bar - * the sort: each time is parsed once, and each month's array only ever grows by `push`. */ + * record whose time does not parse has no day and is left out. Linear in the records, bar + * the sort: each time is parsed once, and each day's array only ever grows by `push`. */ +export function partitionByDay( + records: readonly StoredUsage[] +): ReadonlyMap { + return partitionBy(records, dayOf); +} + export function partitionByMonth( records: readonly StoredUsage[] +): ReadonlyMap { + return partitionBy(records, monthOf); +} + +function partitionBy( + records: readonly StoredUsage[], + partitionOf: (at: string) => string | null ): ReadonlyMap { const dated: { readonly record: StoredUsage; readonly instant: number; readonly id: string }[] = []; for (const record of records) { - if (monthOf(record.at) !== null) { + if (partitionOf(record.at) !== null) { dated.push({ record, instant: Date.parse(record.at), id: id(record) }); } } dated.sort((a, b) => a.instant - b.instant || compareText(a.id, b.id)); - const months = new Map(); + const partitions = new Map(); for (const { record } of dated) { - const month = monthOf(record.at) as string; - const held = months.get(month); - if (held === undefined) months.set(month, [record]); + const partition = partitionOf(record.at) as string; + const held = partitions.get(partition); + if (held === undefined) partitions.set(partition, [record]); else held.push(record); } - return new Map([...months.entries()].sort(([a], [b]) => compareText(a, b))); + return new Map([...partitions.entries()].sort(([a], [b]) => compareText(a, b))); } function isText(value: unknown): value is string { diff --git a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts index a3a9da740..c98318e65 100644 --- a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { + dayOf, + daysAround, monthOf, parseStoredUsage, + partitionByDay, partitionByMonth, type StoredUsage, upsertUsage, @@ -49,6 +52,112 @@ describe("a record belongs to the month of its own time, in UTC", () => { }); }); +describe("a record belongs to the day of its own time, in UTC", () => { + it("reads the day from the instant", () => { + expect(dayOf("2026-10-07T10:00:00.000Z")).toBe("2026-10-07"); + }); + + it("uses UTC at the day boundary, whatever the offset says", () => { + expect(dayOf("2026-10-07T23:30:00-02:00")).toBe("2026-10-08"); + expect(dayOf("2026-10-08T00:30:00+02:00")).toBe("2026-10-07"); + }); + + it("pads a single-digit month and day", () => { + expect(dayOf("2026-02-03T00:00:00Z")).toBe("2026-02-03"); + }); + + it("has none for a time that is not one", () => { + expect(dayOf("yesterday")).toBeNull(); + expect(dayOf("")).toBeNull(); + }); +}); + +describe("an ingest reads its calls' days and the day on each side", () => { + it("reads three days for one call", () => { + expect([...daysAround([stored({ at: "2026-10-07T10:00:00.000Z" })])].sort()).toEqual([ + "2026-10-06", + "2026-10-07", + "2026-10-08", + ]); + }); + + it("reads four days, once each, for calls on two adjacent days", () => { + const days = daysAround([ + stored({ key: "a", at: "2026-10-07T10:00:00.000Z" }), + stored({ key: "b", at: "2026-10-08T10:00:00.000Z" }), + stored({ key: "c", at: "2026-10-08T11:00:00.000Z" }), + ]); + expect([...days].sort()).toEqual(["2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"]); + }); + + it("crosses a month and a year boundary", () => { + expect(daysAround([stored({ at: "2026-03-01T00:30:00.000Z" })])).toContain("2026-02-28"); + expect(daysAround([stored({ at: "2026-12-31T23:59:00.000Z" })])).toContain("2027-01-01"); + }); + + it("takes nothing from a call whose time does not parse, and nothing from no call", () => { + expect(daysAround([stored({ at: "garbage" })]).size).toBe(0); + expect(daysAround([]).size).toBe(0); + }); +}); + +describe("a partition is the day's records in a fixed order", () => { + it("groups by day and orders by time, then key", () => { + const parts = partitionByDay([ + stored({ key: "b", at: "2026-10-02T05:00:00.000Z" }), + stored({ key: "a", at: "2026-10-02T05:00:00.000Z" }), + stored({ key: "c", at: "2026-10-02T01:00:00.000Z" }), + stored({ key: "d", at: "2026-10-03T00:00:00.000Z" }), + ]); + expect([...parts.keys()]).toEqual(["2026-10-02", "2026-10-03"]); + expect(parts.get("2026-10-02")?.map((r) => r.key)).toEqual(["c", "a", "b"]); + }); + + it("lists the days in order, whatever order the records come in", () => { + const parts = partitionByDay([ + stored({ key: "c", at: "2026-12-01T00:00:00.000Z" }), + stored({ key: "a", at: "2026-10-01T00:00:00.000Z" }), + stored({ key: "b", at: "2026-10-02T00:00:00.000Z" }), + ]); + expect([...parts.keys()]).toEqual(["2026-10-01", "2026-10-02", "2026-12-01"]); + }); + + it("orders two records of one instant by their key", () => { + const parts = partitionByDay([stored({ key: "b" }), stored({ key: "a" })]); + expect(parts.get("2026-10-07")?.map((r) => r.key)).toEqual(["a", "b"]); + }); + + it("puts a record with no usable time in no partition", () => { + expect(partitionByDay([stored({ at: "garbage" })]).size).toBe(0); + }); + + it("copies each record a bounded number of times, so a large ledger partitions in linear work", () => { + // Counts every element an array iteration hands out while partitioning: a copy of the + // day's array per record would hand out about n squared of them. + const iterate: unknown = Reflect.get(Array.prototype, Symbol.iterator); + const touched = (n: number): number => { + const records = Array.from({ length: n }, (_, i) => + stored({ key: `k${i}`, at: new Date(Date.UTC(2026, 9, 1) + i * 1000).toISOString() }) + ); + let count = 0; + Reflect.set(Array.prototype, Symbol.iterator, function* counting(this: unknown[]) { + for (let i = 0; i < this.length; i += 1) { + count += 1; + yield this[i]; + } + }); + try { + partitionByDay(records); + } finally { + Reflect.set(Array.prototype, Symbol.iterator, iterate); + } + return count; + }; + expect(touched(2000)).toBeLessThan(2000 * 8); + expect(touched(4000)).toBeLessThan(4000 * 8); + }); +}); + describe("upserting records into what is held", () => { it("names the months it changed: where a record is now, and where a replaced one was", () => { const held = [ From 248cf27510bb1b9c74b1898d5c4e5e9a3fd6ceba Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 09:56:12 +0200 Subject: [PATCH 04/17] perf(telemetry): the ledger is kept by day, and an ingest reads its window One ingested call now loads, folds and writes only its calls' days and the day on each side, plus any monthly partition those days fall in, which it splits into days. Damage and duplicates in the window are rewritten with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_ledger-by-day/phase-2.md | 2 +- .../application/ingest-usage-use-case.ts | 34 +- .../telemetry/application/ledger-upkeep.ts | 23 ++ .../telemetry/domain/ports/usage-ledger.ts | 28 +- .../contexts/telemetry/domain/stored-usage.ts | 43 +-- .../infrastructure/usage-ledger-adapter.ts | 107 +++++-- .../ingest-usage-use-case.unit.test.ts | 54 +++- .../consent-scenarios.integration.test.ts | 2 +- .../domain/stored-usage.unit.test.ts | 86 +---- .../usage-ledger-adapter.integration.test.ts | 299 ++++++++++++++---- .../telemetry-lifecycle.integration.test.ts | 8 +- cli/tests/e2e/telemetry-ingest.e2e.test.ts | 10 +- .../helpers/ports/in-memory-telemetry.ts | 41 ++- 13 files changed, 463 insertions(+), 274 deletions(-) create mode 100644 cli/src/contexts/telemetry/application/ledger-upkeep.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md index f9b6d5fb3..0071eee18 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-2.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- diff --git a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts index 338fad43d..7c1d713c2 100644 --- a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts @@ -1,9 +1,10 @@ import type { UsageLedger } from "../domain/ports/usage-ledger.js"; import type { NotStoredReason } from "../domain/repository-resolution.js"; -import { monthOf, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; +import { dayOf, type StoredUsage } from "../domain/stored-usage.js"; import type { TranscriptPosition } from "../domain/transcript-position.js"; import type { UsageRecord } from "../domain/usage-record.js"; import type { DirectoryOutcome, DirectoryResolver, ResolutionRun } from "./directory-resolver.js"; +import { storeCalls } from "./ledger-upkeep.js"; import type { ReadUsageUseCase, UsageReading } from "./read-usage-use-case.js"; import type { SnapshotBindingsUseCase } from "./snapshot-bindings-use-case.js"; @@ -16,7 +17,7 @@ export interface IngestResult { readonly updated: number; /** Shapes the reader met and did not recognise. */ readonly unrecognised: number; - /** Lines of the ledger that were not records. */ + /** Lines of the partitions this ingest read that were not records: its window only. */ readonly skippedLedgerLines: number; /** Branch declarations newly snapshotted. */ readonly snapshots: number; @@ -113,21 +114,10 @@ export class IngestUsageUseCase { if (outcome.live) live.set(outcome.stored.repository_id, outcome.stored.root); } - let added = 0; - let updated = 0; - let skippedLedgerLines = 0; - if (toStore.length > 0) { - const held = await this.ledger.load(); - skippedLedgerLines = held.skippedLines; - const merged = upsertUsage(held.records, toStore); - added = merged.added; - updated = merged.updated; - // A month that holds a line that is not a record is rewritten with the rest, so the line - // is dropped once and the report stops warning of it. - if (added + updated > 0 || held.damagedMonths.size > 0) { - await this.ledger.save(merged.records, new Set([...merged.months, ...held.damagedMonths])); - } - } + const stored = + toStore.length > 0 + ? await storeCalls(this.ledger, toStore) + : { added: 0, updated: 0, skippedLines: 0 }; await resolver.close(); const consentLogDamaged = resolver.consentLogDamaged; @@ -142,10 +132,10 @@ export class IngestUsageUseCase { return { refused: false, filesRead, - added, - updated, + added: stored.added, + updated: stored.updated, unrecognised: reading.unrecognised.length, - skippedLedgerLines, + skippedLedgerLines: stored.skippedLines, snapshots, notStored, consentLogDamaged, @@ -172,10 +162,10 @@ export class IngestUsageUseCase { }; } - /** A call with no time has no month to be kept in, and one with no directory has no + /** A call with no time has no day to be kept in, and one with no directory has no * repository to be tied to. */ private async outcomeOf(record: UsageRecord, resolver: ResolutionRun): Promise { - if (monthOf(record.at) === null) return { skipped: "undated" }; + if (dayOf(record.at) === null) return { skipped: "undated" }; if (record.cwd === null) return { skipped: "no-cwd" }; return resolver.resolve(record.cwd, record.at); } diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts new file mode 100644 index 000000000..36ded8095 --- /dev/null +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -0,0 +1,23 @@ +import type { UsageLedger } from "../domain/ports/usage-ledger.js"; +import { daysAround, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; + +export interface Stored { + readonly added: number; + readonly updated: number; + /** Lines of the partitions read that were not records. */ + readonly skippedLines: number; +} + +/** Folds incoming calls into the days around them, and rewrites only the days that changed, + * plus any partition read that was damaged or not in daily form, so a month is split and a + * torn line dropped once. Nothing outside the window is read. Runs under the caller's lock. */ +export async function storeCalls( + ledger: UsageLedger, + incoming: readonly StoredUsage[] +): Promise { + const held = await ledger.load(daysAround(incoming)); + const merged = upsertUsage(held.records, incoming); + const partitions = new Set([...merged.days, ...held.damaged, ...held.unsettled]); + if (partitions.size > 0) await ledger.save(merged.records, partitions); + return { added: merged.added, updated: merged.updated, skippedLines: held.skippedLines }; +} diff --git a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts index d6ec0f39a..b8ca6c72a 100644 --- a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts +++ b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts @@ -1,24 +1,34 @@ import type { StoredUsage } from "../stored-usage.js"; import type { TranscriptPosition } from "../transcript-position.js"; +/** A partition is named by its key: `YYYY-MM-DD` for a day, `YYYY-MM` for a month as an + * earlier version wrote it. */ export interface LoadedLedger { - /** One record per `(tool, key)` across every partition. */ + /** One record per `(tool, key)` across every partition read. */ readonly records: readonly StoredUsage[]; - /** Lines that were not records and are gone once the ledger is saved. */ + /** Lines that were not records and are gone once their partition is saved. */ readonly skippedLines: number; - /** The months whose partition held one: saving them repairs them. */ - readonly damagedMonths: ReadonlySet; + /** The partitions read that held one: saving them repairs them. */ + readonly damaged: ReadonlySet; + /** The partitions read that are not in canonical daily form: every monthly one, and every + * one holding a call another partition read also holds. Saving them settles them. */ + readonly unsettled: ReadonlySet; } /** The ledger of billed calls, and where each transcript was read up to. */ export interface UsageLedger { /** Runs `work` while no other process writes the ledger. */ exclusively(work: () => Promise): Promise; - load(): Promise; - /** Makes the ledger hold exactly these records. A partition that already holds what it - * should is not touched. Given `months`, only those partitions are written (or removed once - * empty) and no other is read: the caller vouches that the rest is as it was. */ - save(records: readonly StoredUsage[], months?: ReadonlySet): Promise; + /** Every partition, daily and monthly. Given `days`, only those days, plus, for each month + * one of them falls in that still has its monthly partition, that partition and every day + * of the month: a half-finished split may hold a better snapshot in a day. */ + load(days?: ReadonlySet): Promise; + /** Makes the ledger hold exactly these records, every other partition removed. A partition + * that already holds what it should is not touched. Given `partitions`, a day is written + * from `records` (or removed once empty), and a month has each of its days written that + * `records` hold or that is on disk, then its monthly partition removed, last. No other + * partition is read: the caller vouches that the rest is as it was. */ + save(records: readonly StoredUsage[], partitions?: ReadonlySet): Promise; positions(): Promise>; savePositions(positions: ReadonlyMap): Promise; } diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index e98879a5e..2033d5016 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -20,12 +20,6 @@ const COUNTERS = [ "reasoning", ] as const; -/** `YYYY-MM` in UTC, so a ledger partition does not depend on the machine's time zone. */ -export function monthOf(at: string): string | null { - const instant = Date.parse(at); - return Number.isNaN(instant) ? null : new Date(instant).toISOString().slice(0, 7); -} - /** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone. */ export function dayOf(at: string): string | null { const instant = Date.parse(at); @@ -58,9 +52,9 @@ export interface Upserted { readonly records: readonly StoredUsage[]; readonly added: number; readonly updated: number; - /** The months whose partition no longer says what it did: where each added or replaced + /** The days whose partition no longer says what it did: where each added or replaced * record is now, and where a replaced one was. The rest are as they were. */ - readonly months: ReadonlySet; + readonly days: ReadonlySet; } /** Folds incoming records into what is held, with the same fold that counts a call once. Held @@ -72,7 +66,7 @@ export function upsertUsage( ): Upserted { const before = new Map(held.map((record) => [id(record), record])); const records = foldUsage([...held, ...incoming]); - const months = new Set(); + const days = new Set(); let added = 0; let updated = 0; for (const record of records) { @@ -81,11 +75,11 @@ export function upsertUsage( if (was === undefined) added += 1; else updated += 1; for (const moved of [record, was]) { - const month = moved === undefined ? null : monthOf(moved.at); - if (month !== null) months.add(month); + const day = moved === undefined ? null : dayOf(moved.at); + if (day !== null) days.add(day); } } - return { records, added, updated, months }; + return { records, added, updated, days }; } /** Records grouped by the day of their own time, each day in time order then key, so the @@ -94,36 +88,23 @@ export function upsertUsage( * the sort: each time is parsed once, and each day's array only ever grows by `push`. */ export function partitionByDay( records: readonly StoredUsage[] -): ReadonlyMap { - return partitionBy(records, dayOf); -} - -export function partitionByMonth( - records: readonly StoredUsage[] -): ReadonlyMap { - return partitionBy(records, monthOf); -} - -function partitionBy( - records: readonly StoredUsage[], - partitionOf: (at: string) => string | null ): ReadonlyMap { const dated: { readonly record: StoredUsage; readonly instant: number; readonly id: string }[] = []; for (const record of records) { - if (partitionOf(record.at) !== null) { + if (dayOf(record.at) !== null) { dated.push({ record, instant: Date.parse(record.at), id: id(record) }); } } dated.sort((a, b) => a.instant - b.instant || compareText(a.id, b.id)); - const partitions = new Map(); + const days = new Map(); for (const { record } of dated) { - const partition = partitionOf(record.at) as string; - const held = partitions.get(partition); - if (held === undefined) partitions.set(partition, [record]); + const day = dayOf(record.at) as string; + const held = days.get(day); + if (held === undefined) days.set(day, [record]); else held.push(record); } - return new Map([...partitions.entries()].sort(([a], [b]) => compareText(a, b))); + return new Map([...days.entries()].sort(([a], [b]) => compareText(a, b))); } function isText(value: unknown): value is string { diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 4b3c32a26..772d7c3d2 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -4,12 +4,7 @@ import { isErrnoException } from "../../../kernel/reading/json-file.js"; import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; import type { LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; -import { - monthOf, - parseStoredUsage, - partitionByMonth, - type StoredUsage, -} from "../domain/stored-usage.js"; +import { parseStoredUsage, partitionByDay, type StoredUsage } from "../domain/stored-usage.js"; import { parsePositions, renderPositions, @@ -18,19 +13,29 @@ import { import { foldUsage } from "../domain/usage-fold.js"; import { DirectoryLock, type LockOptions } from "./ledger-lock.js"; -const PARTITION = /^\d{4}-\d{2}\.jsonl$/; +const DAILY = /^\d{4}-\d{2}-\d{2}\.jsonl$/; +const MONTHLY = /^\d{4}-\d{2}\.jsonl$/; +const EXTENSION = ".jsonl"; const OFFSETS_FILE = "offsets.json"; const LOCK_FILE = ".lock"; +function partitionOf(name: string): string { + return name.slice(0, -EXTENSION.length); +} + /** The ledger as files under one private directory. * - * `.jsonl`, one record per line, by the month of the record's own time. A better - * snapshot of a call replaces the one held, and its time can fall in another month, so the - * ledger is never appended to: a changed partition is rewritten whole, by temporary file and - * rename, and an unchanged one is not touched (a save told which months changed reads and - * writes only those). Each call is therefore on disk once, no - * reader ever meets a torn line, and the bytes depend on what the ledger holds and not on how - * it got there. A ledger holds a few hundred bytes a call, so a rewrite stays cheap. + * `.jsonl`, one record per line, by the UTC day of the record's own time. A better + * snapshot of a call replaces the one held, and its time can fall in another day, so the + * ledger is never appended to: a changed day is rewritten whole, by temporary file and + * rename, and an unchanged one is not touched. Each call is therefore on disk once, no reader + * ever meets a torn line, and the bytes depend on what the ledger holds and not on how it got + * there. An ingest reads and writes only the days around its calls, so its cost does not grow + * with the ledger. + * + * `.jsonl` is a month as an earlier version wrote it: still read, and split into its + * days, its own file removed after them, so a crash in between leaves each call in both and + * read once. * * `offsets.json` is where each transcript was read up to; `.lock` serialises writers. */ export class UsageLedgerAdapter implements UsageLedger { @@ -44,41 +49,55 @@ export class UsageLedgerAdapter implements UsageLedger { return new DirectoryLock(this.dir, LOCK_FILE, this.storage, this.lockOptions).exclusively(work); } - async load(): Promise { + async load(days?: ReadonlySet): Promise { + const names = await this.partitions(); const found: StoredUsage[] = []; let skippedLines = 0; - const damagedMonths = new Set(); - for (const name of await this.partitions()) { + const damaged = new Set(); + const unsettled = new Set(); + const heldIn = new Map(); + for (const name of days === undefined ? names : inWindow(names, days)) { + const partition = partitionOf(name); + if (MONTHLY.test(name)) unsettled.add(partition); const text = (await readTextIfPresent(join(this.dir, name))) ?? ""; for (const line of text.split("\n")) { if (line === "") continue; const record = parseStoredUsage(line); if (record === null) { skippedLines += 1; - damagedMonths.add(name.slice(0, -".jsonl".length)); - } else found.push(record); + damaged.add(partition); + continue; + } + found.push(record); + const id = `${record.tool}\u0000${record.key}`; + const first = heldIn.get(id); + if (first === undefined) heldIn.set(id, partition); + else if (first !== partition) unsettled.add(first).add(partition); } } - return { records: foldUsage(found), skippedLines, damagedMonths }; + return { records: foldUsage(found), skippedLines, damaged, unsettled }; } - async save(records: readonly StoredUsage[], only?: ReadonlySet): Promise { + async save(records: readonly StoredUsage[], partitions?: ReadonlySet): Promise { await this.storage.ensureDirectory(this.dir); - const months = partitionByMonth( - only === undefined - ? records - : records.filter((record) => only.has(monthOf(record.at) as string)) - ); - for (const [month, inMonth] of months) { - const path = join(this.dir, `${month}.jsonl`); - const text = inMonth.map((record) => JSON.stringify(record)).join("\n"); - const content = `${text}\n`; + const names = await this.partitions(); + const named = (day: string): boolean => + partitions === undefined || partitions.has(day) || partitions.has(day.slice(0, 7)); + const days = partitionByDay(records); + for (const [day, inDay] of days) { + if (!named(day)) continue; + const path = join(this.dir, `${day}${EXTENSION}`); + const content = `${inDay.map((record) => JSON.stringify(record)).join("\n")}\n`; if ((await readTextIfPresent(path)) !== content) await this.storage.replace(path, content); } - // A record whose better snapshot moved to another month can leave its old month empty. - for (const name of await this.partitions()) { - const month = name.slice(0, -".jsonl".length); - if ((only === undefined || only.has(month)) && !months.has(month)) { + // A record whose better snapshot moved to another day can leave its old day empty. + for (const name of names.filter((name) => DAILY.test(name))) { + const day = partitionOf(name); + if (named(day) && !days.has(day)) await rm(join(this.dir, name)); + } + // Last: until every day of a month is written, its monthly file still holds the calls. + for (const name of names.filter((name) => MONTHLY.test(name))) { + if (partitions === undefined || partitions.has(partitionOf(name))) { await rm(join(this.dir, name)); } } @@ -95,10 +114,28 @@ export class UsageLedgerAdapter implements UsageLedger { private async partitions(): Promise { try { - return (await readdir(this.dir)).filter((name) => PARTITION.test(name)); + return (await readdir(this.dir)).filter((name) => DAILY.test(name) || MONTHLY.test(name)); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") return []; throw error; } } } + +/** The days asked for, and every partition of a month one of them falls in that still has its + * monthly file: a half-finished split may hold a better snapshot in any day of that month. */ +function inWindow(names: readonly string[], days: ReadonlySet): string[] { + const touched = new Set([...days].map((day) => day.slice(0, 7))); + const splitting = new Set( + names + .filter((name) => MONTHLY.test(name)) + .map(partitionOf) + .filter((month) => touched.has(month)) + ); + return names.filter((name) => { + const partition = partitionOf(name); + return MONTHLY.test(name) + ? splitting.has(partition) + : days.has(partition) || splitting.has(partition.slice(0, 7)); + }); +} diff --git a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts index c261e34dc..fbb080a35 100644 --- a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts @@ -317,39 +317,67 @@ describe("ingesting usage into the ledger", () => { ]); }); - it("tells the ledger to rewrite only the month a new call falls in", async () => { + it("tells the ledger to load only its calls' days and the day on each side", async () => { const s = setup(); - s.transcripts.files.set("/t/1.jsonl", [line("A", 10, {}, "2026-09-07T10:00:00.000Z")]); + s.transcripts.files.set("/t/1.jsonl", [ + line("A", 10, {}, "2026-10-07T10:00:00.000Z"), + line("B", 20, {}, "2026-10-08T10:00:00.000Z"), + ]); + await s.ingest.execute(); + expect([...(s.ledger.loadedDays ?? [])].sort()).toEqual([ + "2026-10-06", + "2026-10-07", + "2026-10-08", + "2026-10-09", + ]); + }); + + it("tells the ledger to rewrite only the day a new call falls in", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10, {}, "2026-10-06T10:00:00.000Z")]); await s.ingest.execute(); s.transcripts.files.set("/t/1.jsonl", [ - line("A", 10, {}, "2026-09-07T10:00:00.000Z"), + line("A", 10, {}, "2026-10-06T10:00:00.000Z"), line("B", 20, {}, "2026-10-07T10:00:00.000Z"), ]); await s.ingest.execute(); - expect([...(s.ledger.savedMonths ?? [])]).toEqual(["2026-10"]); + expect([...(s.ledger.savedPartitions ?? [])]).toEqual(["2026-10-07"]); + expect(s.ledger.records.map((r) => r.key)).toEqual(["msg_A:req_A", "msg_B:req_B"]); }); - it("also rewrites a month that holds a line that is not a record, whatever the call that came", async () => { + it("also rewrites a damaged partition its window read, whatever the call that came", async () => { const s = setup(); - s.ledger.damaged.add("2026-08"); + s.ledger.damaged.add("2026-10-06"); s.transcripts.files.set("/t/1.jsonl", [line("A", 10)]); await s.ingest.execute(); - expect([...(s.ledger.savedMonths ?? [])].sort()).toEqual(["2026-08", "2026-10"]); + expect([...(s.ledger.savedPartitions ?? [])].sort()).toEqual(["2026-10-06", "2026-10-07"]); }); - it("repairs a damaged month even when every call it read was already held", async () => { + it("repairs damage in its window even when every call it read was already held", async () => { const s = setup(); s.transcripts.files.set("/t/1.jsonl", [line("A", 10)]); await s.ingest.execute(); - s.ledger.damaged.add("2026-10"); + s.ledger.damaged.add("2026-10-07"); s.ledger.events.length = 0; s.ledger.stored = new Map(); - s.ledger.savedMonths = undefined; + s.ledger.savedPartitions = undefined; const again = await s.ingest.execute(); - expect(again).toMatchObject({ added: 0, updated: 0 }); + expect(again).toMatchObject({ added: 0, updated: 0, skippedLedgerLines: 1 }); expect(s.ledger.events).toContain("save"); - // The month that was damaged is the one saved, and no other. - expect([...(s.ledger.savedMonths ?? [])]).toEqual(["2026-10"]); + // The partition that was damaged is the one saved, and no other. + expect([...(s.ledger.savedPartitions ?? [])]).toEqual(["2026-10-07"]); + }); + + it("finishes a monthly partition its window read even when every call was already held", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10)]); + await s.ingest.execute(); + s.ledger.unsettled.add("2026-10"); + s.ledger.stored = new Map(); + s.ledger.savedPartitions = undefined; + expect(await s.ingest.execute()).toMatchObject({ added: 0, updated: 0 }); + expect([...(s.ledger.savedPartitions ?? [])]).toEqual(["2026-10"]); + expect(s.ledger.records).toHaveLength(1); }); it("changes nothing when the same transcripts are ingested again", async () => { diff --git a/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts b/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts index 0431c245c..451fbd6cb 100644 --- a/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts +++ b/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts @@ -168,7 +168,7 @@ class World { const dir = join(this.sandbox.telemetry, "ledger"); if (!existsSync(dir)) return []; return readdirSync(dir) - .filter((name) => /^\d{4}-\d{2}\.jsonl$/u.test(name)) + .filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/u.test(name)) .flatMap((name) => readFileSync(join(dir, name), "utf8").split("\n")) .filter((line) => line !== "") .map((line) => String(JSON.parse(line).key).replace(/^msg_(.+):req_.*$/u, "$1")) diff --git a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts index c98318e65..4d0cf063f 100644 --- a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from "vitest"; import { dayOf, daysAround, - monthOf, parseStoredUsage, partitionByDay, - partitionByMonth, type StoredUsage, upsertUsage, } from "../../../../src/contexts/telemetry/domain/stored-usage.js"; @@ -32,26 +30,6 @@ function stored(overrides: Partial = {}): StoredUsage { }; } -describe("a record belongs to the month of its own time, in UTC", () => { - it("reads the month from the instant", () => { - expect(monthOf("2026-10-07T10:00:00.000Z")).toBe("2026-10"); - }); - - it("uses UTC at the month boundary, whatever the offset says", () => { - expect(monthOf("2026-10-31T23:30:00-05:00")).toBe("2026-11"); - expect(monthOf("2026-11-01T00:30:00+05:00")).toBe("2026-10"); - }); - - it("pads a single-digit month", () => { - expect(monthOf("2026-02-03T00:00:00Z")).toBe("2026-02"); - }); - - it("has none for a time that is not one", () => { - expect(monthOf("yesterday")).toBeNull(); - expect(monthOf("")).toBeNull(); - }); -}); - describe("a record belongs to the day of its own time, in UTC", () => { it("reads the day from the instant", () => { expect(dayOf("2026-10-07T10:00:00.000Z")).toBe("2026-10-07"); @@ -159,7 +137,7 @@ describe("a partition is the day's records in a fixed order", () => { }); describe("upserting records into what is held", () => { - it("names the months it changed: where a record is now, and where a replaced one was", () => { + it("names the days it changed: where a record is now, and where a replaced one was", () => { const held = [ stored({ key: "a", at: "2026-08-02T00:00:00.000Z" }), stored({ key: "b", at: "2026-09-30T23:59:59.000Z", output: 1 }), @@ -170,8 +148,8 @@ describe("upserting records into what is held", () => { stored({ key: "b", at: "2026-10-01T00:00:01.000Z", output: 9 }), stored({ key: "d", at: "2026-10-05T00:00:00.000Z" }), ]); - expect([...result.months].sort()).toEqual(["2026-09", "2026-10"]); - expect(upsertUsage(held, [held[0] as StoredUsage]).months.size).toBe(0); + expect([...result.days].sort()).toEqual(["2026-09-30", "2026-10-01", "2026-10-05"]); + expect(upsertUsage(held, [held[0] as StoredUsage]).days.size).toBe(0); }); it("adds a key not yet held", () => { @@ -226,64 +204,6 @@ describe("upserting records into what is held", () => { }); }); -describe("a partition is the month's records in a fixed order", () => { - it("groups by month and orders by time, then key", () => { - const parts = partitionByMonth([ - stored({ key: "b", at: "2026-10-02T00:00:00.000Z" }), - stored({ key: "a", at: "2026-10-02T00:00:00.000Z" }), - stored({ key: "c", at: "2026-10-01T00:00:00.000Z" }), - stored({ key: "d", at: "2026-11-01T00:00:00.000Z" }), - ]); - expect([...parts.keys()]).toEqual(["2026-10", "2026-11"]); - expect(parts.get("2026-10")?.map((r) => r.key)).toEqual(["c", "a", "b"]); - }); - - it("lists the months in order, whatever order the records come in", () => { - const parts = partitionByMonth([ - stored({ key: "c", at: "2026-12-01T00:00:00.000Z" }), - stored({ key: "a", at: "2026-10-01T00:00:00.000Z" }), - stored({ key: "b", at: "2026-11-01T00:00:00.000Z" }), - ]); - expect([...parts.keys()]).toEqual(["2026-10", "2026-11", "2026-12"]); - }); - - it("orders two records of one instant by their key", () => { - const parts = partitionByMonth([stored({ key: "b" }), stored({ key: "a" })]); - expect(parts.get("2026-10")?.map((r) => r.key)).toEqual(["a", "b"]); - }); - - it("puts a record with no usable time in no partition, and says so", () => { - const parts = partitionByMonth([stored({ at: "garbage" })]); - expect(parts.size).toBe(0); - }); - - it("copies each record a bounded number of times, so a large ledger partitions in linear work", () => { - // Counts every element an array iteration hands out while partitioning: a copy of the - // month's array per record would hand out about n squared of them. - const iterate: unknown = Reflect.get(Array.prototype, Symbol.iterator); - const touched = (n: number): number => { - const records = Array.from({ length: n }, (_, i) => - stored({ key: `k${i}`, at: new Date(Date.UTC(2026, 9, 1) + i * 1000).toISOString() }) - ); - let count = 0; - Reflect.set(Array.prototype, Symbol.iterator, function* counting(this: unknown[]) { - for (let i = 0; i < this.length; i += 1) { - count += 1; - yield this[i]; - } - }); - try { - partitionByMonth(records); - } finally { - Reflect.set(Array.prototype, Symbol.iterator, iterate); - } - return count; - }; - expect(touched(2000)).toBeLessThan(2000 * 8); - expect(touched(4000)).toBeLessThan(4000 * 8); - }); -}); - describe("a stored line is read back or refused", () => { it("round-trips a record", () => { const record = stored({ cache_write_1h: 3, reasoning: 2, cwd: "/x", git_branch: "b" }); diff --git a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts index 68f7d38b7..4cf73bc70 100644 --- a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; import type { StoredUsage } from "../../../../src/contexts/telemetry/domain/stored-usage.js"; import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; @@ -41,16 +42,43 @@ function stored(overrides: Partial = {}): StoredUsage { }; } +const PARTITION_NAME = /^\d{4}-\d{2}(-\d{2})?\.jsonl$/; + const partitions = async (): Promise => - (await readdir(dir)).filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).sort(); + (await readdir(dir)).filter((name) => PARTITION_NAME.test(name)).sort(); + +/** A partition as an earlier version wrote it, records given in their canonical order. */ +const lines = (records: readonly StoredUsage[]): string => + `${records.map((record) => JSON.stringify(record)).join("\n")}\n`; + +async function writePartition(name: string, content: string): Promise { + await new PrivateStorageAdapter().ensureDirectory(dir); + await writeFile(join(dir, name), content); +} + +/** Storage whose `n`-th replace fails, as a crash between two writes would leave it. */ +class FailingStorage implements PrivateStorage { + private readonly inner = new PrivateStorageAdapter(); + private calls = 0; + constructor(private readonly failAt: number) {} + ensureDirectory(path: string): Promise { + return this.inner.ensureDirectory(path); + } + async replace(path: string, content: string): Promise { + this.calls += 1; + if (this.calls === this.failAt) throw new Error("disk full"); + await this.inner.replace(path, content); + } + append(path: string, content: string): Promise { + return this.inner.append(path, content); + } +} + +const NOTHING = { records: [], skippedLines: 0, damaged: new Set(), unsettled: new Set() }; describe("the ledger on disk", () => { it("holds nothing before anything is saved", async () => { - expect(await ledger.load()).toEqual({ - records: [], - skippedLines: 0, - damagedMonths: new Set(), - }); + expect(await ledger.load()).toEqual(NOTHING); }); it("round-trips what is saved", async () => { @@ -59,103 +87,111 @@ describe("the ledger on disk", () => { expect((await ledger.load()).records).toEqual(records); }); - it("files each record under the month of its own time, in UTC", async () => { + it("files each record under the day of its own time, in UTC", async () => { await ledger.save([ stored({ key: "a", at: "2026-10-31T23:30:00-05:00" }), stored({ key: "b", at: "2026-10-15T00:00:00Z" }), stored({ key: "c", at: "2026-12-01T00:00:00Z" }), ]); - expect(await partitions()).toEqual(["2026-10.jsonl", "2026-11.jsonl", "2026-12.jsonl"]); - expect(await readFile(join(dir, "2026-11.jsonl"), "utf8")).toContain('"key":"a"'); + expect(await partitions()).toEqual([ + "2026-10-15.jsonl", + "2026-11-01.jsonl", + "2026-12-01.jsonl", + ]); + expect(await readFile(join(dir, "2026-11-01.jsonl"), "utf8")).toContain('"key":"a"'); }); it("writes one record per line, ending in a newline", async () => { await ledger.save([stored({ key: "a" }), stored({ key: "b" })]); - const text = await readFile(join(dir, "2026-10.jsonl"), "utf8"); + const text = await readFile(join(dir, "2026-10-07.jsonl"), "utf8"); expect(text.endsWith("\n")).toBe(true); expect(text.trimEnd().split("\n")).toHaveLength(2); }); it("writes the same bytes whatever order the records come in", async () => { - const a = stored({ key: "a", at: "2026-10-01T00:00:00Z" }); - const b = stored({ key: "b", at: "2026-10-02T00:00:00Z" }); - const c = stored({ key: "c", at: "2026-10-03T00:00:00Z" }); + const a = stored({ key: "a", at: "2026-10-07T01:00:00Z" }); + const b = stored({ key: "b", at: "2026-10-07T02:00:00Z" }); + const c = stored({ key: "c", at: "2026-10-07T03:00:00Z" }); await ledger.save([c, a, b]); - const first = await readFile(join(dir, "2026-10.jsonl"), "utf8"); + const first = await readFile(join(dir, "2026-10-07.jsonl"), "utf8"); await rm(dir, { recursive: true }); await ledger.save([b, c, a]); - expect(await readFile(join(dir, "2026-10.jsonl"), "utf8")).toBe(first); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe(first); }); it("does not touch a partition that already holds what it should", async () => { await ledger.save([stored({ key: "a" }), stored({ key: "z", at: "2026-11-02T00:00:00Z" })]); - const octoberBefore = await stat(join(dir, "2026-10.jsonl")); - const novemberBefore = await stat(join(dir, "2026-11.jsonl")); + const octoberBefore = await stat(join(dir, "2026-10-07.jsonl")); + const novemberBefore = await stat(join(dir, "2026-11-02.jsonl")); await ledger.save([ stored({ key: "a" }), stored({ key: "z", at: "2026-11-02T00:00:00Z", output: 99 }), ]); - expect((await stat(join(dir, "2026-10.jsonl"))).ino).toBe(octoberBefore.ino); - expect((await stat(join(dir, "2026-11.jsonl"))).ino).not.toBe(novemberBefore.ino); + expect((await stat(join(dir, "2026-10-07.jsonl"))).ino).toBe(octoberBefore.ino); + expect((await stat(join(dir, "2026-11-02.jsonl"))).ino).not.toBe(novemberBefore.ino); }); - it("rewrites only the months it is told changed, and reads no other partition", async () => { + it("rewrites only the days it is told changed, and reads no other partition", async () => { await ledger.save([ stored({ key: "a", at: "2026-08-02T00:00:00Z" }), stored({ key: "b", at: "2026-09-02T00:00:00Z" }), ]); - const septemberBefore = await stat(join(dir, "2026-09.jsonl")); - await writeFile(join(dir, "2026-08.jsonl"), "{not a record}\n"); + const septemberBefore = await stat(join(dir, "2026-09-02.jsonl")); + await writeFile(join(dir, "2026-08-02.jsonl"), "{not a record}\n"); await ledger.save( [ stored({ key: "a", at: "2026-08-02T00:00:00Z" }), stored({ key: "b", at: "2026-09-02T00:00:00Z", output: 99 }), stored({ key: "c", at: "2026-10-02T00:00:00Z" }), ], - new Set(["2026-09", "2026-10"]) + new Set(["2026-09-02", "2026-10-02"]) ); - expect(await readFile(join(dir, "2026-08.jsonl"), "utf8")).toBe("{not a record}\n"); - expect((await stat(join(dir, "2026-09.jsonl"))).ino).not.toBe(septemberBefore.ino); - expect(await readFile(join(dir, "2026-09.jsonl"), "utf8")).toContain('"output":99'); - expect(await partitions()).toEqual(["2026-08.jsonl", "2026-09.jsonl", "2026-10.jsonl"]); + expect(await readFile(join(dir, "2026-08-02.jsonl"), "utf8")).toBe("{not a record}\n"); + expect((await stat(join(dir, "2026-09-02.jsonl"))).ino).not.toBe(septemberBefore.ino); + expect(await readFile(join(dir, "2026-09-02.jsonl"), "utf8")).toContain('"output":99'); + expect(await partitions()).toEqual([ + "2026-08-02.jsonl", + "2026-09-02.jsonl", + "2026-10-02.jsonl", + ]); }); - it("removes a told month that no longer holds anything", async () => { + it("removes a told day that no longer holds anything", async () => { await ledger.save([stored({ key: "a", at: "2026-08-02T00:00:00Z" })]); await ledger.save( - [stored({ key: "a", at: "2026-09-02T00:00:00Z", output: 2 })], - new Set(["2026-08", "2026-09"]) + [stored({ key: "a", at: "2026-08-03T00:00:00Z", output: 2 })], + new Set(["2026-08-02", "2026-08-03"]) ); - expect(await partitions()).toEqual(["2026-09.jsonl"]); + expect(await partitions()).toEqual(["2026-08-03.jsonl"]); }); - it("leaves one live record when a better snapshot moves a call to another month", async () => { + it("leaves one live record when a better snapshot moves a call to another day", async () => { await ledger.save([stored({ output: 10, at: "2026-10-31T23:59:59.000Z" })]); await ledger.save([stored({ output: 90, at: "2026-11-01T00:00:01.000Z" })]); - expect(await partitions()).toEqual(["2026-11.jsonl"]); + expect(await partitions()).toEqual(["2026-11-01.jsonl"]); const { records } = await ledger.load(); expect(records.map((r) => r.output)).toEqual([90]); }); - it("reads only month files, and leaves the offsets and lock beside them alone", async () => { + it("reads only partition files, and leaves the offsets, roots, consents and lock beside them alone", async () => { await ledger.savePositions(new Map([["/t", { offset: 1, size: 1, identity: "i" }]])); - await writeFile(join(dir, "x2026-10.jsonl"), "garbage\n"); - await writeFile(join(dir, "2026-10.jsonl.bak"), "garbage\n"); - await writeFile(join(dir, "26-10.jsonl"), "garbage\n"); - expect(await ledger.load()).toEqual({ - records: [], - skippedLines: 0, - damagedMonths: new Set(), - }); + const beside = [ + "x2026-10-07.jsonl", + "2026-10-07.jsonl.bak", + "26-10-07.jsonl", + "2026-10-7.jsonl", + "2026-10.jsonl.bak", + "consents.jsonl", + "roots.json", + ".lock", + ]; + for (const name of beside) await writeFile(join(dir, name), "garbage\n"); + expect(await ledger.load()).toEqual(NOTHING); await ledger.save([stored()]); expect((await ledger.positions()).size).toBe(1); - expect((await readdir(dir)).sort()).toEqual([ - "2026-10.jsonl", - "2026-10.jsonl.bak", - "26-10.jsonl", - "offsets.json", - "x2026-10.jsonl", - ]); + expect((await readdir(dir)).sort()).toEqual( + [...beside, "2026-10-07.jsonl", "offsets.json"].sort() + ); }); it("fails to load from a ledger directory it cannot list, instead of reading it as empty", async () => { @@ -166,34 +202,43 @@ describe("the ledger on disk", () => { ).rejects.toThrow(); }); - it("folds a call found in two partitions down to its best snapshot", async () => { - await ledger.save([]); - await writeFile(join(dir, "2026-10.jsonl"), `${JSON.stringify(stored({ output: 10 }))}\n`); - await writeFile( - join(dir, "2026-11.jsonl"), - `${JSON.stringify(stored({ output: 90, at: "2026-11-01T00:00:00Z" }))}\n` + it("folds a call found in two days down to its best snapshot, and says both are unsettled", async () => { + await writePartition( + "2026-10-31.jsonl", + lines([stored({ output: 10, at: "2026-10-31T23:59:00Z" })]) + ); + await writePartition( + "2026-11-01.jsonl", + lines([stored({ output: 90, at: "2026-11-01T00:00:00Z" })]) + ); + await writePartition( + "2026-11-02.jsonl", + lines([stored({ key: "other", at: "2026-11-02T00:00:00Z" })]) ); - expect((await ledger.load()).records.map((r) => r.output)).toEqual([90]); + const loaded = await ledger.load(); + expect(loaded.records.map((r) => r.output)).toEqual([90, 10]); + expect([...loaded.unsettled].sort()).toEqual(["2026-10-31", "2026-11-01"]); }); it("counts a line that is not a record and drops it at the next save", async () => { await ledger.save([stored()]); - const path = join(dir, "2026-10.jsonl"); + const path = join(dir, "2026-10-07.jsonl"); await writeFile(path, `${await readFile(path, "utf8")}{torn\n\n`); const loaded = await ledger.load(); expect(loaded.skippedLines).toBe(1); - expect([...loaded.damagedMonths]).toEqual(["2026-10"]); + expect([...loaded.damaged]).toEqual(["2026-10-07"]); expect(loaded.records).toHaveLength(1); await ledger.save([...loaded.records, stored({ key: "b" })]); expect(await readFile(path, "utf8")).not.toContain("torn"); }); - it("names only the months that hold a line that is not a record", async () => { + it("names only the partitions that hold a line that is not a record", async () => { await ledger.save([stored(), stored({ key: "n", at: "2026-11-02T00:00:00Z" })]); - await writeFile(join(dir, "2026-10.jsonl"), "{torn\n"); + await writeFile(join(dir, "2026-10-07.jsonl"), "{torn\n"); const loaded = await ledger.load(); - expect([...loaded.damagedMonths]).toEqual(["2026-10"]); - await ledger.save(loaded.records, loaded.damagedMonths); + expect([...loaded.damaged]).toEqual(["2026-10-07"]); + expect(loaded.unsettled.size).toBe(0); + await ledger.save(loaded.records, loaded.damaged); expect((await ledger.load()).skippedLines).toBe(0); }); @@ -203,12 +248,140 @@ describe("the ledger on disk", () => { await ledger.save([stored()]); await ledger.savePositions(new Map([["/t", { offset: 1, size: 1, identity: "i" }]])); expect((await stat(dir)).mode & 0o777).toBe(0o700); - expect((await stat(join(dir, "2026-10.jsonl"))).mode & 0o777).toBe(0o600); + expect((await stat(join(dir, "2026-10-07.jsonl"))).mode & 0o777).toBe(0o600); expect((await stat(join(dir, "offsets.json"))).mode & 0o777).toBe(0o600); } ); }); +describe("reading a window of days", () => { + it("reads only the days it is given, and opens no other partition", async () => { + await ledger.save([ + stored({ key: "a", at: "2026-10-06T10:00:00Z" }), + stored({ key: "b", at: "2026-10-07T10:00:00Z" }), + stored({ key: "c", at: "2026-10-08T10:00:00Z" }), + ]); + await writeFile(join(dir, "2026-10-06.jsonl"), "{torn\n"); + expect(await ledger.load(new Set(["2026-10-07"]))).toEqual({ + ...NOTHING, + records: [stored({ key: "b", at: "2026-10-07T10:00:00Z" })], + }); + }); + + it("reads nothing for an empty window", async () => { + await ledger.save([stored()]); + expect(await ledger.load(new Set())).toEqual(NOTHING); + }); + + it("reads a monthly partition, and the daily ones of its month, when a day of the window falls in it", async () => { + const early = stored({ key: "early", at: "2026-09-10T10:00:00Z" }); + const late = stored({ key: "late", at: "2026-09-28T10:00:00Z" }); + const daily = stored({ key: "daily", at: "2026-09-25T10:00:00Z" }); + await writePartition("2026-09.jsonl", lines([early, late])); + await writePartition("2026-09-25.jsonl", lines([daily])); + for (const loaded of [await ledger.load(), await ledger.load(new Set(["2026-09-10"]))]) { + expect(loaded.records.map((r) => r.key)).toEqual(["daily", "early", "late"]); + expect([...loaded.unsettled]).toEqual(["2026-09"]); + } + expect(await ledger.load(new Set(["2026-10-07"]))).toEqual(NOTHING); + }); + + it("reads a monthly partition its window reaches by the day before", async () => { + await writePartition("2026-09.jsonl", lines([stored({ at: "2026-09-30T10:00:00Z" })])); + const loaded = await ledger.load(new Set(["2026-09-30", "2026-10-01"])); + expect(loaded.records).toHaveLength(1); + expect([...loaded.unsettled]).toEqual(["2026-09"]); + }); + + it("finds a call held in two days of its window", async () => { + await writePartition( + "2026-10-06.jsonl", + lines([stored({ output: 1, at: "2026-10-06T23:59:00Z" })]) + ); + await writePartition("2026-10-07.jsonl", lines([stored({ output: 9 })])); + const loaded = await ledger.load(new Set(["2026-10-06", "2026-10-07", "2026-10-08"])); + expect(loaded.records.map((r) => r.output)).toEqual([9]); + expect([...loaded.unsettled].sort()).toEqual(["2026-10-06", "2026-10-07"]); + }); +}); + +describe("splitting a monthly partition into its days", () => { + const september = [ + stored({ key: "a", at: "2026-09-01T00:00:00Z" }), + stored({ key: "b", at: "2026-09-15T12:00:00Z" }), + stored({ key: "c", at: "2026-09-30T23:59:00Z" }), + ]; + + it("writes each day of the month, then removes the monthly file, in the bytes a fresh ledger has", async () => { + await writePartition("2026-09.jsonl", lines(september)); + await writePartition( + "2026-09-20.jsonl", + lines([stored({ key: "gone", at: "2026-09-20T00:00:00Z" })]) + ); + await ledger.save(september, new Set(["2026-09"])); + const fresh = new UsageLedgerAdapter(join(root, "fresh"), new PrivateStorageAdapter()); + await fresh.save(september); + const names = ["2026-09-01.jsonl", "2026-09-15.jsonl", "2026-09-30.jsonl"]; + expect(await partitions()).toEqual(names); + for (const name of names) { + expect(await readFile(join(dir, name), "utf8")).toBe( + await readFile(join(root, "fresh", name), "utf8") + ); + } + }); + + it("writes only the days of the month it is told", async () => { + await writePartition("2026-09.jsonl", lines(september)); + await ledger.save( + [...september, stored({ key: "d", at: "2026-10-01T00:00:00Z" })], + new Set(["2026-09"]) + ); + expect(await partitions()).not.toContain("2026-10-01.jsonl"); + }); + + it("removes the monthly file only after its days are written", async () => { + await writePartition("2026-09.jsonl", lines(september)); + const crashing = new UsageLedgerAdapter(dir, new FailingStorage(2)); + await expect(crashing.save(september, new Set(["2026-09"]))).rejects.toThrow("disk full"); + expect(await partitions()).toContain("2026-09.jsonl"); + const { records } = await ledger.load(); + expect(records.map((r) => r.key)).toEqual(["a", "b", "c"]); + }); + + it("removes every monthly file when told to hold exactly some records", async () => { + await writePartition("2026-09.jsonl", lines(september)); + await ledger.save(september); + expect(await partitions()).toEqual([ + "2026-09-01.jsonl", + "2026-09-15.jsonl", + "2026-09-30.jsonl", + ]); + }); + + it("keeps the better snapshot a half-finished split already wrote, and finishes it", async () => { + const worse = stored({ key: "a", output: 1, at: "2026-09-10T10:00:00Z" }); + const better = stored({ key: "a", output: 90, at: "2026-09-10T10:00:01Z" }); + const other = stored({ key: "b", at: "2026-09-20T10:00:00Z" }); + await writePartition("2026-09.jsonl", lines([worse, other])); + await writePartition("2026-09-10.jsonl", lines([better])); + const loaded = await ledger.load(new Set(["2026-09-20"])); + expect(loaded.records).toEqual([better, other]); + expect([...loaded.unsettled].sort()).toEqual(["2026-09", "2026-09-10"]); + await ledger.save(loaded.records, loaded.unsettled); + expect(await partitions()).toEqual(["2026-09-10.jsonl", "2026-09-20.jsonl"]); + expect(await ledger.load()).toEqual({ ...NOTHING, records: [better, other] }); + }); + + it("counts a line of a monthly file that is not a record, and drops it with the split", async () => { + await writePartition("2026-09.jsonl", `${lines(september)}{torn\n`); + const loaded = await ledger.load(); + expect(loaded.skippedLines).toBe(1); + expect([...loaded.damaged]).toEqual(["2026-09"]); + await ledger.save(loaded.records, new Set([...loaded.damaged, ...loaded.unsettled])); + expect(await ledger.load()).toEqual({ ...NOTHING, records: september }); + }); +}); + describe("the positions of the transcripts read", () => { it("round-trip", async () => { const positions = new Map([ diff --git a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts index af4b73321..45968c934 100644 --- a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts @@ -332,9 +332,13 @@ function writeTranscript(cwd: string, ids: readonly string[], name = "s-1", at?: utimesSync(file, written, written); } +/** How many calls the ledger's days hold. */ const stored = () => { - const file = join(box.telemetry, "ledger", "2026-10.jsonl"); - return existsSync(file) ? read(file).split("\n").filter(Boolean).length : 0; + const dir = join(box.telemetry, "ledger"); + if (!existsSync(dir)) return 0; + return readdirSync(dir) + .filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/u.test(name)) + .flatMap((name) => read(join(dir, name)).split("\n").filter(Boolean)).length; }; describe("measurement starts at on", () => { diff --git a/cli/tests/e2e/telemetry-ingest.e2e.test.ts b/cli/tests/e2e/telemetry-ingest.e2e.test.ts index ce6ef3a97..5f2450c54 100644 --- a/cli/tests/e2e/telemetry-ingest.e2e.test.ts +++ b/cli/tests/e2e/telemetry-ingest.e2e.test.ts @@ -78,7 +78,7 @@ async function held(): Promise> { } async function ledgerLines(): Promise { - const text = await readFile(join(telemetry, "ledger", "2026-10.jsonl"), "utf8"); + const text = await readFile(join(telemetry, "ledger", "2026-10-07.jsonl"), "utf8"); return text.split("\n").filter((line) => line !== ""); } @@ -89,13 +89,13 @@ describe("aidd telemetry ingest", () => { expect(first.exitCode).toBe(0); expect(first.stdout).toContain("4 calls added"); expect(first.stdout).toContain("1 call from a clone with no consent at that time"); - const before = await readFile(join(telemetry, "ledger", "2026-10.jsonl"), "utf8"); + const before = await readFile(join(telemetry, "ledger", "2026-10-07.jsonl"), "utf8"); const keys = (await ledgerLines()).map((line) => JSON.parse(line).key).sort(); expect(keys).toEqual(["msg_A:req_A", "msg_B:req_B", "msg_B:req_B#advisor0", "msg_C:req_C"]); const second = await ingest(); expect(second.stdout).toContain("Read 0 transcripts: 0 calls added, 0 updated"); - expect(await readFile(join(telemetry, "ledger", "2026-10.jsonl"), "utf8")).toBe(before); + expect(await readFile(join(telemetry, "ledger", "2026-10-07.jsonl"), "utf8")).toBe(before); }); it("ties each call to the repository id, never to its url or path", async () => { @@ -131,7 +131,7 @@ describe("aidd telemetry ingest", () => { "msg_C:req_C", ]); expect((await readdir(join(telemetry, "ledger"))).sort()).toEqual([ - "2026-10.jsonl", + "2026-10-07.jsonl", "consents.jsonl", "offsets.json", "roots.json", @@ -204,7 +204,7 @@ describe("aidd telemetry ingest", () => { expect(run.stdout).toContain("0 calls added"); // No clone ever opted in: no transcript is read, so no call is even counted. expect(run.stdout).toContain("Read 0 transcripts"); - expect(await readdir(join(telemetry, "ledger"))).not.toContain("2026-10.jsonl"); + expect(await readdir(join(telemetry, "ledger"))).not.toContain("2026-10-07.jsonl"); }); it("stores nothing for a clone whose key was set by hand, though it says 2", async () => { diff --git a/cli/tests/helpers/ports/in-memory-telemetry.ts b/cli/tests/helpers/ports/in-memory-telemetry.ts index 35d941ad9..0e61bffe8 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry.ts @@ -44,7 +44,7 @@ import type { UsageLedger, } from "../../../src/contexts/telemetry/domain/ports/usage-ledger.js"; import type { RepositoryResolution } from "../../../src/contexts/telemetry/domain/repository-resolution.js"; -import type { StoredUsage } from "../../../src/contexts/telemetry/domain/stored-usage.js"; +import { dayOf, type StoredUsage } from "../../../src/contexts/telemetry/domain/stored-usage.js"; import { type CloneConsentReading, type ConsentReading, @@ -113,24 +113,47 @@ export class InMemoryLedger implements UsageLedger { } } - async load(): Promise { + /** The window the last load was given; `undefined` when it read everything. */ + loadedDays: ReadonlySet | undefined; + + async load(days?: ReadonlySet): Promise { this.events.push("load"); + this.loadedDays = days; + const read = + days === undefined + ? this.records + : this.records.filter((record) => days.has(dayOf(record.at) ?? "")); return { - records: foldUsage(this.records), + records: foldUsage(read), skippedLines: this.damaged.size, - damagedMonths: new Set(this.damaged), + damaged: new Set(this.damaged), + unsettled: new Set(this.unsettled), }; } - /** Months whose partition holds a line that is not a record. */ + /** Partitions holding a line that is not a record. Saving one repairs it. */ damaged = new Set(); - savedMonths: ReadonlySet | undefined; + /** Partitions not in canonical daily form. Saving one settles it. */ + unsettled = new Set(); + savedPartitions: ReadonlySet | undefined; - async save(records: readonly StoredUsage[], months?: ReadonlySet): Promise { + /** Named partitions take their records from `records`; the rest keep theirs, as on disk. */ + async save(records: readonly StoredUsage[], partitions?: ReadonlySet): Promise { this.events.push("save"); - this.savedMonths = months; + this.savedPartitions = partitions; if (this.failSave) throw new Error("disk full"); - this.records = [...records]; + const named = (record: StoredUsage): boolean => { + const day = dayOf(record.at) ?? ""; + return partitions === undefined || partitions.has(day) || partitions.has(day.slice(0, 7)); + }; + this.records = foldUsage([ + ...this.records.filter((record) => !named(record)), + ...records.filter(named), + ]); + for (const partition of partitions ?? [...this.damaged, ...this.unsettled]) { + this.damaged.delete(partition); + this.unsettled.delete(partition); + } } async positions(): Promise> { From 055819d23ed16fed417e342d8d8ced65e4569b73 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 09:59:35 +0200 Subject: [PATCH 05/17] feat(telemetry): a report leaves the ledger settled, by day The report rewrites, folded, every partition that is damaged, monthly, or holds a call another one holds, so the bytes on disk are those of one ingest of the same calls whatever order the ingests ran in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_ledger-by-day/phase-3.md | 2 +- .../telemetry/application/ledger-upkeep.ts | 13 +- .../report/report-usage-use-case.ts | 6 +- .../ledger-upkeep.integration.test.ts | 154 ++++++++++++++++++ ...ledger-upkeep.property.integration.test.ts | 136 ++++++++++++++++ .../report/report-usage-use-case.unit.test.ts | 30 ++++ 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md index 930a1d793..fb66c3159 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-3.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts index 36ded8095..7a2cf0c0d 100644 --- a/cli/src/contexts/telemetry/application/ledger-upkeep.ts +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -1,4 +1,4 @@ -import type { UsageLedger } from "../domain/ports/usage-ledger.js"; +import type { LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; import { daysAround, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; export interface Stored { @@ -21,3 +21,14 @@ export async function storeCalls( if (partitions.size > 0) await ledger.save(merged.records, partitions); return { added: merged.added, updated: merged.updated, skippedLines: held.skippedLines }; } + +/** Reads every partition and rewrites, folded, each one that is damaged or not in daily form: + * a monthly one, or one holding a call another also holds. After it, the ledger is + * byte-identical to a single ingest of the same calls; a settled day is not touched. Runs + * under the caller's lock. */ +export async function settleLedger(ledger: UsageLedger): Promise { + const held = await ledger.load(); + const partitions = new Set([...held.unsettled, ...held.damaged]); + if (partitions.size > 0) await ledger.save(held.records, partitions); + return held; +} diff --git a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts index 1fbf63e99..1e9ed1b38 100644 --- a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts @@ -8,6 +8,7 @@ import { } from "../../domain/report/usage-report.js"; import type { NotStoredReason } from "../../domain/repository-resolution.js"; import type { IngestUsageUseCase } from "../ingest-usage-use-case.js"; +import { settleLedger } from "../ledger-upkeep.js"; import type { DeclaredBindings } from "./declared-bindings.js"; export interface ReportRequest { @@ -44,7 +45,8 @@ export type ReportResult = /** Reads what is new, then answers what the stored calls consumed in a period, split along one * axis. The ledger holds facts only: what a call belongs to is worked out here, every time, from - * what people declared, so a declaration made later moves earlier work. */ + * what people declared, so a declaration made later moves earlier work. It also leaves the + * ledger settled: one call in one partition, by day, whatever order the ingests ran in. */ export class ReportUsageUseCase { constructor( private readonly ingest: IngestUsageUseCase, @@ -56,7 +58,7 @@ export class ReportUsageUseCase { async execute(request: ReportRequest): Promise { const ingested = await this.ingest.execute(); if (ingested.refused) return { status: "refused" }; - const held = await this.ledger.exclusively(() => this.ledger.load()); + const held = await this.ledger.exclusively(() => settleLedger(this.ledger)); const records = held.records.filter((record) => inPeriod(record.at, request.period)); const report = buildReport( records, diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts new file mode 100644 index 000000000..cce892cb8 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -0,0 +1,154 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + settleLedger, + storeCalls, +} from "../../../../src/contexts/telemetry/application/ledger-upkeep.js"; +import { buildReport } from "../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import { + partitionByDay, + type StoredUsage, +} from "../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; + +let root: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-ledger-upkeep-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function stored(key: string, at: string, output: number): StoredUsage { + return { + tool: "claude-code", + tool_version: "2.1.0", + key, + session_id: "s-1", + agent: "main", + at, + model: "m", + input: 1, + output, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: "repo-1", + }; +} + +const CALLS = [ + stored("a", "2026-09-01T08:00:00.000Z", 10), + stored("b", "2026-09-15T12:00:00.000Z", 20), + stored("c", "2026-09-30T23:59:00.000Z", 30), + stored("d", "2026-10-01T00:01:00.000Z", 40), + stored("e", "2026-10-20T10:00:00.000Z", 50), +]; + +/** Each day's canonical lines, as a fresh ledger writes them. */ +function daysOf(records: readonly StoredUsage[]): Map { + const days = new Map(); + for (const [day, inDay] of partitionByDay(records)) { + days.set(`${day}.jsonl`, `${inDay.map((record) => JSON.stringify(record)).join("\n")}\n`); + } + return days; +} + +/** A month as an earlier version wrote it: its days' lines, in order, in one file. */ +function monthsOf(records: readonly StoredUsage[]): Map { + const months = new Map(); + for (const [name, text] of daysOf(records)) { + const month = `${name.slice(0, 7)}.jsonl`; + months.set(month, (months.get(month) ?? "") + text); + } + return months; +} + +async function ledgerAt(name: string, files: ReadonlyMap) { + const dir = join(root, name); + await mkdir(dir, { recursive: true }); + for (const [file, text] of files) await writeFile(join(dir, file), text); + return { dir, ledger: new UsageLedgerAdapter(dir, new PrivateStorageAdapter()) }; +} + +async function bytes(dir: string): Promise> { + const found: Record = {}; + for (const name of (await readdir(dir)).sort()) { + if (/^\d{4}-\d{2}(-\d{2})?\.jsonl$/.test(name)) { + found[name] = await readFile(join(dir, name), "utf8"); + } + } + return found; +} + +const NO_FACTS = { declarations: [], carries: [], branches: new Map() }; + +async function totals(ledger: UsageLedgerAdapter) { + return buildReport((await ledger.load()).records, NO_FACTS, null, "total").totals; +} + +const days = daysOf(CALLS); +const months = monthsOf(CALLS); +const septemberDays = [...days].filter(([name]) => name.startsWith("2026-09")); + +/** Every disk state a split of September can be caught in, October still monthly. */ +const SPLIT_STATES: readonly (readonly [string, Map])[] = [ + ["the monthly file only", new Map(months)], + ["the monthly file and some of its days", new Map([...months, ...septemberDays.slice(0, 1)])], + ["the monthly file and all of its days", new Map([...months, ...septemberDays])], + [ + "its days only", + new Map([...septemberDays, ...[...months].filter(([name]) => name.startsWith("2026-10"))]), + ], +]; + +describe("migrating a ledger kept by month", () => { + it.each(SPLIT_STATES)("reports the same totals with %s", async (_state, files) => { + const before = await ledgerAt("before", months); + const during = await ledgerAt("during", files); + expect(await totals(during.ledger)).toEqual(await totals(before.ledger)); + }); + + it.each(SPLIT_STATES)("leaves only the days once settled, from %s", async (_state, files) => { + const { dir, ledger } = await ledgerAt("settling", files); + const fresh = await ledgerAt("fresh", new Map()); + await fresh.ledger.save(CALLS); + const held = await totals(ledger); + await settleLedger(ledger); + expect(await bytes(dir)).toEqual(await bytes(fresh.dir)); + expect(await totals(ledger)).toEqual(held); + }); + + it.each(SPLIT_STATES)( + "leaves only the days once an ingest's window reaches both months, from %s", + async (_state, files) => { + const { dir, ledger } = await ledgerAt("ingesting", files); + const result = await storeCalls(ledger, [stored("c", "2026-09-30T23:59:00.000Z", 30)]); + expect(result).toMatchObject({ added: 0, updated: 0 }); + expect(await bytes(dir)).toEqual(Object.fromEntries([...days].sort())); + } + ); + + it("settles nothing, and writes nothing, on a ledger already in days", async () => { + const { dir, ledger } = await ledgerAt("settled", days); + const before = await bytes(dir); + await writeFile(join(dir, "2026-09-01.jsonl.bak"), "untouched\n"); + expect((await settleLedger(ledger)).unsettled.size).toBe(0); + expect(await bytes(dir)).toEqual(before); + }); + + it("drops a line that is not a record, wherever it is, once settled", async () => { + const damaged = new Map(days); + damaged.set("2026-09-15.jsonl", `${damaged.get("2026-09-15.jsonl")}{torn\n`); + const { dir, ledger } = await ledgerAt("damaged", damaged); + expect((await settleLedger(ledger)).skippedLines).toBe(1); + expect(await bytes(dir)).toEqual(Object.fromEntries([...days].sort())); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts new file mode 100644 index 000000000..8e1b23172 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts @@ -0,0 +1,136 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as fc from "fast-check"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + settleLedger, + storeCalls, +} from "../../../../src/contexts/telemetry/application/ledger-upkeep.js"; +import { + partitionByDay, + type StoredUsage, +} from "../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { foldUsage } from "../../../../src/contexts/telemetry/domain/usage-fold.js"; +import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; + +let root: string; +let runs = 0; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-ledger-property-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const DAY_MS = 86_400_000; +/** Where each key's snapshots gather: month and year ends, so a window crosses them. Four-digit + * years only, as a partition name has. */ +const ANCHORS = [ + "2025-01-31T23:50:00.000Z", + "2025-12-31T23:55:00.000Z", + "2026-02-28T23:58:00.000Z", + "2026-06-15T12:00:00.000Z", + "2026-10-01T00:02:00.000Z", + "2027-12-31T23:59:00.000Z", +].map((at) => Date.parse(at)); + +const call = fc + .record({ + keyIndex: fc.nat(ANCHORS.length - 1), + tool: fc.constantFrom("claude-code" as const, "codex" as const), + // Snapshots of one call are minutes apart; days apart is the case never observed. + offset: fc.oneof( + { weight: 9, arbitrary: fc.integer({ min: -30 * 60_000, max: 30 * 60_000 }) }, + { weight: 1, arbitrary: fc.integer({ min: -10 * DAY_MS, max: 10 * DAY_MS }) } + ), + output: fc.nat(5), + session: fc.constantFrom("s-1", "s-2"), + }) + .map( + ({ keyIndex, tool, offset, output, session }): StoredUsage => ({ + tool, + tool_version: "1.0.0", + key: `k${keyIndex}`, + session_id: session, + agent: "main", + at: new Date((ANCHORS[keyIndex] as number) + offset).toISOString(), + model: "m", + input: 1, + output, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: "repo-1", + }) + ); + +const scenario = fc.array(call, { minLength: 1, maxLength: 12 }).chain((calls) => { + const each = (arbitrary: fc.Arbitrary) => + fc.array(arbitrary, { minLength: calls.length, maxLength: calls.length }); + return fc.record({ + calls: fc.constant(calls), + order: fc.shuffledSubarray( + calls.map((_, index) => index), + { minLength: calls.length, maxLength: calls.length } + ), + batchOf: each(fc.nat(4)), + monthly: each(fc.boolean()), + }); +}); + +/** What an earlier version would have written for these calls: one file per month. */ +function monthsOf(records: readonly StoredUsage[]): Map { + const months = new Map(); + for (const [day, inDay] of partitionByDay(foldUsage(records))) { + const month = `${day.slice(0, 7)}.jsonl`; + const text = `${inDay.map((record) => JSON.stringify(record)).join("\n")}\n`; + months.set(month, (months.get(month) ?? "") + text); + } + return months; +} + +async function freshLedger(files: ReadonlyMap = new Map()) { + runs += 1; + const dir = join(root, `ledger-${runs}`); + await mkdir(dir, { recursive: true }); + for (const [name, text] of files) await writeFile(join(dir, name), text); + return { dir, ledger: new UsageLedgerAdapter(dir, new PrivateStorageAdapter()) }; +} + +async function bytes(dir: string): Promise> { + const found: Record = {}; + for (const name of (await readdir(dir)).sort()) { + if (/^\d{4}-\d{2}(-\d{2})?\.jsonl$/.test(name)) { + found[name] = await readFile(join(dir, name), "utf8"); + } + } + return found; +} + +describe("the ledger's bytes, once a report has run", () => { + it("are those of one ingest of the same calls, whatever the order, grouping and starting format", async () => { + await fc.assert( + fc.asyncProperty(scenario, async ({ calls, order, batchOf, monthly }) => { + const earlier = calls.filter((_, index) => monthly[index]); + const { dir, ledger } = await freshLedger(monthsOf(earlier)); + const batches: StoredUsage[][] = [[], [], [], [], []]; + for (const index of order) { + if (!monthly[index]) batches[batchOf[index] as number]?.push(calls[index] as StoredUsage); + } + for (const batch of batches) if (batch.length > 0) await storeCalls(ledger, batch); + await settleLedger(ledger); + + const reference = await freshLedger(); + await storeCalls(reference.ledger, calls); + await settleLedger(reference.ledger); + expect(await bytes(dir)).toEqual(await bytes(reference.dir)); + }) + ); + }, 120_000); +}); diff --git a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts index cf240d9ed..25e5e06d8 100644 --- a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts @@ -104,6 +104,36 @@ async function rows(s: ReturnType, axis: ReportAxis, period = ANY) } describe("reporting usage", () => { + it("settles a call held in two partitions under the ledger lock", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); + await s.report.execute({ axis: "total", period: ANY }); + s.ledger.unsettled = new Set(["2026-10-06", "2026-10-07"]); + s.events.length = 0; + await s.report.execute({ axis: "total", period: ANY }); + expect(s.events.slice(-4)).toEqual(["lock", "load", "save", "unlock"]); + expect(s.ledger.savedPartitions).toEqual(new Set(["2026-10-06", "2026-10-07"])); + expect(s.ledger.records).toHaveLength(1); + }); + + it("writes nothing when the ledger is settled", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); + await s.report.execute({ axis: "total", period: ANY }); + s.events.length = 0; + await s.report.execute({ axis: "total", period: ANY }); + expect(s.events).not.toContain("save"); + }); + + it("repairs a damaged partition outside any ingest window", async () => { + const s = setup(); + s.ledger.damaged.add("2026-01-15"); + const result = await s.report.execute({ axis: "total", period: ANY }); + expect(result).toMatchObject({ coverage: { skippedLedgerLines: 1 } }); + expect(s.ledger.savedPartitions).toEqual(new Set(["2026-01-15"])); + expect((await s.ledger.load()).skippedLines).toBe(0); + }); + it("reads new transcripts first, so the report holds what was just written", async () => { const s = setup(); s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); From fbac39c279341a7467e85f2edbc3356db6db3db2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 10:01:04 +0200 Subject: [PATCH 06/17] docs(telemetry): the contract and the code map say the ledger is by day Pins that forget removes daily and monthly partitions as the ledger, never as an earlier day file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 2 +- .../2026_10/2026_10_15_ledger-by-day/phase-4.md | 2 +- cli/aidd_docs/memory/telemetry.md | 1 + .../telemetry/domain/legacy/legacy-locations.ts | 3 ++- .../domain/legacy/legacy-locations.unit.test.ts | 2 +- ...easurement-erasure-adapter.integration.test.ts | 15 +++++++++++++++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 141982b3f..3662c1b39 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -111,7 +111,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | Store | Writer | Readers | Holds | | --- | --- | --- | --- | -| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by month of `at`. Only a changed month is rewritten, whole, by rename; no new bytes, no write | +| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions that share a call or hold a line that is not a record. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest whose window touches it or by a report, then removed | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md index 2e3dc5320..e22f6371c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-4.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index d6f108e03..1ce471288 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -54,6 +54,7 @@ flowchart LR | What a clone is | `domain/consent/clone-identity.ts` | | When a clone consented | `domain/consent/consent-history.ts`, `application/consent-log.ts` | | Which transcripts are read | `application/ingest-usage-use-case.ts`, `application/read-usage-use-case.ts` | +| How the ledger lies on disk, and what an ingest reads | `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts` | | What `on` and `forget` clean up from an earlier measurement | `domain/legacy/`, `application/switch/`, `application/forget/` | | The report axes and the envelope | `domain/report/usage-report.ts`, `application/report/report-envelope.ts` | diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-locations.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-locations.ts index d1b42212e..4a6e3a619 100644 --- a/cli/src/contexts/telemetry/domain/legacy/legacy-locations.ts +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-locations.ts @@ -7,7 +7,8 @@ export interface LegacyLocations { readonly identityFiles: readonly string[]; } -/** A day file's name, and nothing the ledger names its partitions by. */ +/** A day file's name in the directories the previous version wrote to. The ledger names its + * days the same way, inside `ledger/`, which is never one of them. */ export const DAY_FILE = /^\d{4}-\d{2}-\d{2}\.jsonl$/; function set(value: string | undefined): string | null { diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-locations.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-locations.unit.test.ts index d57cf28b6..dc6cfe2b0 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/legacy-locations.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-locations.unit.test.ts @@ -51,7 +51,7 @@ describe("where the previous version wrote", () => { }); describe("a day file's name", () => { - it("is a date, not a ledger month", () => { + it("is a date, not a month", () => { expect(DAY_FILE.test("2026-10-07.jsonl")).toBe(true); expect(DAY_FILE.test("2026-10.jsonl")).toBe(false); expect(DAY_FILE.test("offsets.json")).toBe(false); diff --git a/cli/tests/contexts/telemetry/infrastructure/forget/measurement-erasure-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/forget/measurement-erasure-adapter.integration.test.ts index 85b8fc4a2..4dd3fefe6 100644 --- a/cli/tests/contexts/telemetry/infrastructure/forget/measurement-erasure-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/forget/measurement-erasure-adapter.integration.test.ts @@ -66,6 +66,21 @@ describe("what measurement keeps", () => { ]); }); + it("removes the ledger's daily and monthly partitions as the ledger, never as an earlier day file", async () => { + write(join(telemetry, "ledger", "2026-10-07.jsonl")); + write(join(telemetry, "ledger", "2026-09.jsonl")); + write(join(telemetry, "ledger", "offsets.json")); + write(join(sink, "2026-10-05.jsonl")); + const found = await erasure().inventory(); + expect(found.map((e) => [e.kind, e.path.replace(root, ""), e.files])).toEqual([ + ["previous-day-file", join("/", "previous-sink", "2026-10-05.jsonl"), 1], + ["ledger", join("/", "telemetry", "ledger"), 3], + ]); + await erasure().erase(); + expect(existsSync(join(telemetry, "ledger"))).toBe(false); + expect(existsSync(join(sink, "2026-10-05.jsonl"))).toBe(false); + }); + it("counts the files a directory holds, nested ones too, and not its lock", async () => { write(join(telemetry, "ledger", "a.jsonl")); write(join(telemetry, "ledger", "nested", "b.jsonl")); From 316adb8fc5d31231ef5e3dba5ddbb802f383077f Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 10:44:06 +0200 Subject: [PATCH 07/17] test(telemetry): a day holding one call twice is unsettled too Kills the two mutants the telemetry run left in the ledger adapter: an unanchored monthly name, and a duplicate inside one day going unseen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md | 2 +- .../contexts/telemetry/domain/ports/usage-ledger.ts | 3 ++- .../telemetry/infrastructure/usage-ledger-adapter.ts | 2 +- .../usage-ledger-adapter.integration.test.ts | 11 +++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md index 3e8ca9683..2225d0e8e 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/phase-5.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- diff --git a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts index b8ca6c72a..08df7dbb6 100644 --- a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts +++ b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts @@ -11,7 +11,8 @@ export interface LoadedLedger { /** The partitions read that held one: saving them repairs them. */ readonly damaged: ReadonlySet; /** The partitions read that are not in canonical daily form: every monthly one, and every - * one holding a call another partition read also holds. Saving them settles them. */ + * one holding a call twice or a call another partition read also holds. Saving them settles + * them. */ readonly unsettled: ReadonlySet; } diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 772d7c3d2..991c273fb 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -72,7 +72,7 @@ export class UsageLedgerAdapter implements UsageLedger { const id = `${record.tool}\u0000${record.key}`; const first = heldIn.get(id); if (first === undefined) heldIn.set(id, partition); - else if (first !== partition) unsettled.add(first).add(partition); + else unsettled.add(first).add(partition); } } return { records: foldUsage(found), skippedLines, damaged, unsettled }; diff --git a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts index 4cf73bc70..d36d16592 100644 --- a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts @@ -177,6 +177,7 @@ describe("the ledger on disk", () => { await ledger.savePositions(new Map([["/t", { offset: 1, size: 1, identity: "i" }]])); const beside = [ "x2026-10-07.jsonl", + "x2026-10.jsonl", "2026-10-07.jsonl.bak", "26-10-07.jsonl", "2026-10-7.jsonl", @@ -220,6 +221,16 @@ describe("the ledger on disk", () => { expect([...loaded.unsettled].sort()).toEqual(["2026-10-31", "2026-11-01"]); }); + it("says a day holding one call twice is unsettled, and settles it to one line", async () => { + await writePartition("2026-10-07.jsonl", lines([stored({ output: 1 }), stored({ output: 9 })])); + const loaded = await ledger.load(); + expect([...loaded.unsettled]).toEqual(["2026-10-07"]); + await ledger.save(loaded.records, loaded.unsettled); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( + lines([stored({ output: 9 })]) + ); + }); + it("counts a line that is not a record and drops it at the next save", async () => { await ledger.save([stored()]); const path = join(dir, "2026-10-07.jsonl"); From 9b0f11c5b54476a67d78df489c0a61b92ee26c44 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 10:44:47 +0200 Subject: [PATCH 08/17] docs(telemetry): the ledger by day is implemented Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md index be2d6d513..b538cf147 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/plan.md @@ -1,6 +1,6 @@ --- objective: "One ingested call costs the days it touches, not the whole ledger: the ledger is partitioned by UTC day, monthly partitions migrate, and a report leaves the bytes canonical." -status: in-progress +status: implemented --- From 4c000346d30a13f98fc8efe353011b261121dd93 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 10:59:29 +0200 Subject: [PATCH 09/17] fix(telemetry): a call's day never depends on the machine's time zone dayOf now gives no day for a time without an explicit zone (Z or +-hh:mm), or whose UTC day falls outside four-digit years. Such a call is counted undated at ingest, and such a ledger line is counted and dropped as not a record, instead of landing in a file DAILY never reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../contexts/telemetry/domain/stored-usage.ts | 20 ++++++++++---- .../ingest-usage-use-case.unit.test.ts | 8 ++++++ .../domain/stored-usage.unit.test.ts | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index 2033d5016..7077efed6 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -20,10 +20,19 @@ const COUNTERS = [ "reasoning", ] as const; -/** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone. */ +/** A time that names its own zone and a four-digit year: anything else would be read in the + * machine's zone, or give a day no partition name can hold. */ +const ZONED = /^\d{4}-\d{2}-\d{2}T[^Z+-]*(Z|[+-]\d{2}:\d{2})$/; +const FOUR_DIGIT_DAY = /^\d{4}-\d{2}-\d{2}$/; + +/** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone, or + * `null` for a time with no zone, or whose UTC day falls outside four-digit years. */ export function dayOf(at: string): string | null { + if (!ZONED.test(at)) return null; const instant = Date.parse(at); - return Number.isNaN(instant) ? null : new Date(instant).toISOString().slice(0, 10); + if (Number.isNaN(instant)) return null; + const day = new Date(instant).toISOString().slice(0, 10); + return FOUR_DIGIT_DAY.test(day) ? day : null; } const DAY_MS = 24 * 60 * 60 * 1000; @@ -84,8 +93,8 @@ export function upsertUsage( /** Records grouped by the day of their own time, each day in time order then key, so the * bytes of a partition depend on what it holds and never on the order it was found in. A - * record whose time does not parse has no day and is left out. Linear in the records, bar - * the sort: each time is parsed once, and each day's array only ever grows by `push`. */ + * record with no day (see `dayOf`) is left out. Linear in the records, bar the sort: each + * time is parsed once, and each day's array only ever grows by `push`. */ export function partitionByDay( records: readonly StoredUsage[] ): ReadonlyMap { @@ -127,7 +136,8 @@ function isStoredUsage( (USAGE_AGENTS as readonly unknown[]).includes(object.agent) && isText(object.key) && isText(object.session_id) && - isText(object.at) && + typeof object.at === "string" && + dayOf(object.at) !== null && isText(object.repository_id) && isNullableText(object.tool_version) && isNullableText(object.model) && diff --git a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts index fbb080a35..b50f31bbe 100644 --- a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts @@ -668,6 +668,14 @@ describe("where a call was made", () => { expect(result.notStored.undated).toBe(1); }); + it("counts a call whose time names no zone as undated, and keeps it out of the ledger", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 1, {}, "2026-10-15T22:00:00")]); + const result = await s.ingest.execute(); + expect(result.notStored.undated).toBe(1); + expect(result.added).toBe(0); + }); + it("keeps what it learned about a directory only when it learned something new", async () => { const s = setup(); s.transcripts.files.set("/t/1.jsonl", [line("A", 1)]); diff --git a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts index 4d0cf063f..fac5b3d19 100644 --- a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts @@ -48,6 +48,30 @@ describe("a record belongs to the day of its own time, in UTC", () => { expect(dayOf("yesterday")).toBeNull(); expect(dayOf("")).toBeNull(); }); + + it("has none for a time with no zone, so the day never depends on the machine's", () => { + expect(dayOf("2026-10-15T22:00:00")).toBeNull(); + expect(dayOf("2026-10-15T22:00:00.000")).toBeNull(); + expect(dayOf("2026-10-15")).toBeNull(); + expect(dayOf("2026-10-15T22:00:00+0200")).toBeNull(); + }); + + it("has none for a year that is not four digits, before or after the zone is applied", () => { + expect(dayOf("+010000-01-01T00:00:00Z")).toBeNull(); + expect(dayOf("10000-01-01T00:00:00Z")).toBeNull(); + expect(dayOf("9999-12-31T23:30:00-02:00")).toBeNull(); + expect(dayOf("0000-01-01T00:30:00+02:00")).toBeNull(); + }); + + it("keeps the last and first day a four-digit year has", () => { + expect(dayOf("9999-12-31T23:59:59.999Z")).toBe("9999-12-31"); + expect(dayOf("0000-01-01T00:00:00Z")).toBe("0000-01-01"); + }); + + it("maps an offset to the UTC day it falls in", () => { + expect(dayOf("2026-10-16T01:00:00+02:00")).toBe("2026-10-15"); + expect(dayOf("2026-10-15T22:00:00z")).toBeNull(); + }); }); describe("an ingest reads its calls' days and the day on each side", () => { @@ -248,6 +272,8 @@ describe("a stored line is read back or refused", () => { ["a numeric repository", JSON.stringify({ ...stored(), repository_id: 4 })], ["a numeric key", JSON.stringify({ ...stored(), key: 4 })], ["a numeric time", JSON.stringify({ ...stored(), at: 4 })], + ["a time with no zone", JSON.stringify({ ...stored(), at: "2026-10-15T22:00:00" })], + ["a time past year 9999", JSON.stringify({ ...stored(), at: "+010000-01-01T00:00:00Z" })], ["a numeric session", JSON.stringify({ ...stored(), session_id: 4 })], ["a numeric version", JSON.stringify({ ...stored(), tool_version: 4 })], ["a numeric model", JSON.stringify({ ...stored(), model: 4 })], From e17563e7ddb4dec473587f17d69e9c43721bce79 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:00:37 +0200 Subject: [PATCH 10/17] fix(telemetry): the report says a damaged ledger line is dropped The report settles the ledger, so a line that is not a record is gone after it, as after an ingest. Both now say so in the same words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../display/telemetry/telemetry-report-display.ts | 3 ++- .../display/telemetry/telemetry-report-display.unit.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/presentation/display/telemetry/telemetry-report-display.ts b/cli/src/presentation/display/telemetry/telemetry-report-display.ts index ac7315ad2..d4d37049b 100644 --- a/cli/src/presentation/display/telemetry/telemetry-report-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-report-display.ts @@ -124,8 +124,9 @@ function coverageLines(coverage: ReportCoverage): string[] { } if (coverage.consentLogDamaged) lines.push(DAMAGED_CONSENT_LOG_WORDS); if (coverage.skippedLedgerLines > 0) { + const n = coverage.skippedLedgerLines; lines.push( - `${count(coverage.skippedLedgerLines, "ledger line", "ledger lines")} not a record and skipped.` + `${count(n, "ledger line", "ledger lines")} ${n === 1 ? "was" : "were"} not a record and dropped.` ); } return lines; diff --git a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts index f5b1298a9..04c0fdb5b 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts @@ -243,12 +243,12 @@ describe("printing a usage report", () => { expect(one_).toContain(`1 shape${UNRECOGNISED}`); }); - it("says when ledger lines were skipped", () => { + it("says when ledger lines were dropped", () => { const lines = printed(result("total", [], tally(0, [0, 0, 0, 0]), { skippedLedgerLines: 2 })); - expect(lines.at(-1)).toBe("2 ledger lines not a record and skipped."); + expect(lines.at(-1)).toBe("2 ledger lines were not a record and dropped."); expect( printed(result("total", [], tally(0, [0, 0, 0, 0]), { skippedLedgerLines: 1 })).at(-1) - ).toBe("1 ledger line not a record and skipped."); + ).toBe("1 ledger line was not a record and dropped."); expect(printed(result("total", [], tally(0, [0, 0, 0, 0]))).join("\n")).not.toContain("ledger"); }); From 06f9944f7b6ad2c84c054647f8cc8104860d10f2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:01:36 +0200 Subject: [PATCH 11/17] refactor(telemetry): a split month is found without a filter that changed nothing A day's key never equals a month's, so filtering names to monthly files before matching them against the touched months was redundant: the equivalent mutant the review saw survive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/infrastructure/usage-ledger-adapter.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 991c273fb..e1aaad3b1 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -126,12 +126,8 @@ export class UsageLedgerAdapter implements UsageLedger { * monthly file: a half-finished split may hold a better snapshot in any day of that month. */ function inWindow(names: readonly string[], days: ReadonlySet): string[] { const touched = new Set([...days].map((day) => day.slice(0, 7))); - const splitting = new Set( - names - .filter((name) => MONTHLY.test(name)) - .map(partitionOf) - .filter((month) => touched.has(month)) - ); + // A day's key never equals a month's, so only a monthly file can match a touched month. + const splitting = new Set(names.map(partitionOf).filter((month) => touched.has(month))); return names.filter((name) => { const partition = partitionOf(name); return MONTHLY.test(name) From 785cecd71567abf8fc9b51320fdceb5f90e0b7a5 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:02:30 +0200 Subject: [PATCH 12/17] docs(telemetry): the ledger's invariants say when they hold Once on disk and order-independent bytes hold after a report settles the ledger; an ingest reads its window plus a month it splits; save removes a named day it holds no records for rather than writing it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../contexts/telemetry/application/ledger-upkeep.ts | 3 ++- .../contexts/telemetry/domain/ports/usage-ledger.ts | 7 ++++--- .../telemetry/infrastructure/usage-ledger-adapter.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts index 7a2cf0c0d..0fdfa8feb 100644 --- a/cli/src/contexts/telemetry/application/ledger-upkeep.ts +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -10,7 +10,8 @@ export interface Stored { /** Folds incoming calls into the days around them, and rewrites only the days that changed, * plus any partition read that was damaged or not in daily form, so a month is split and a - * torn line dropped once. Nothing outside the window is read. Runs under the caller's lock. */ + * torn line dropped once. Nothing outside the window is read, except a monthly file it + * splits and every day of that month. Runs under the caller's lock. */ export async function storeCalls( ledger: UsageLedger, incoming: readonly StoredUsage[] diff --git a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts index 08df7dbb6..d1a53f6e4 100644 --- a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts +++ b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts @@ -26,9 +26,10 @@ export interface UsageLedger { load(days?: ReadonlySet): Promise; /** Makes the ledger hold exactly these records, every other partition removed. A partition * that already holds what it should is not touched. Given `partitions`, a day is written - * from `records` (or removed once empty), and a month has each of its days written that - * `records` hold or that is on disk, then its monthly partition removed, last. No other - * partition is read: the caller vouches that the rest is as it was. */ + * from `records`, or removed when they hold none for it; a month has each of its days + * written from `records`, each of its days on disk they hold none for removed, then its + * monthly partition removed, last. No other partition is read: the caller vouches that the + * rest is as it was. */ save(records: readonly StoredUsage[], partitions?: ReadonlySet): Promise; positions(): Promise>; savePositions(positions: ReadonlyMap): Promise; diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index e1aaad3b1..573693a6e 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -28,10 +28,13 @@ function partitionOf(name: string): string { * `.jsonl`, one record per line, by the UTC day of the record's own time. A better * snapshot of a call replaces the one held, and its time can fall in another day, so the * ledger is never appended to: a changed day is rewritten whole, by temporary file and - * rename, and an unchanged one is not touched. Each call is therefore on disk once, no reader - * ever meets a torn line, and the bytes depend on what the ledger holds and not on how it got - * there. An ingest reads and writes only the days around its calls, so its cost does not grow - * with the ledger. + * rename, and an unchanged one is not touched, so no reader ever meets a torn line. An ingest + * reads the days around its calls, plus every day of a month it splits, and writes only what + * changed, so its cost does not grow with the ledger. Between reports, a call whose snapshots + * fell outside that window may sit in two days, and a crashed split leaves it in a month and a + * day: every reader folds at read, so it is still counted once. Once a report has settled the + * ledger, each call is on disk once and the bytes depend on what the ledger holds and not on + * how it got there. * * `.jsonl` is a month as an earlier version wrote it: still read, and split into its * days, its own file removed after them, so a crash in between leaves each call in both and From 6b7e3ed585513939a88eac6cceeb34df057a4652 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:03:18 +0200 Subject: [PATCH 13/17] docs(telemetry): the contract records the re-measured before and after Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md index c113ba4e9..270f0d897 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md @@ -16,6 +16,8 @@ One ingested call costs the size of the whole ledger (#1001). Measured on `next` | `save(month)` | 6.0 s | stringifies 265k records, re-reads the file to compare, rewrites it | | total | ~11.5 s | | +The round-1 review re-ran the same bench on `next` code, with the real `PrivateStorageAdapter`, and got 0.41 s, 0.37 s and 0.87 s, ~1.65 s in all. The first figure is kept as measured; the setup that gave it is not recorded. On the ledger by day (`9b0f11c5`) the same bench gives 58-64 ms per call, and 1.6-1.7 s for the one-time split of a monthly file. Criterion #1 holds against either "before". + ## Decision Partition the ledger by the UTC **day** of a record's `at` (`ledger/.jsonl`) instead of its month. Everything else stays: a changed partition is rewritten whole by rename, an unchanged one is not touched, records are in canonical order, one call is one line. From 84a4b1269d241b8c417d350676ec021f87bcb718 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:04:07 +0200 Subject: [PATCH 14/17] test(telemetry): the settled-ledger property covers damage and re-ingest Adds torn lines in a monthly file and in a stray day, calls already held in a monthly file read again, 200 runs, an assertion that the bytes are the canonical partitionByDay(foldUsage(calls)) with each call once, and that a second settle finds nothing to do and changes nothing. Red when settle ignores damaged partitions or save keeps an emptied day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- ...ledger-upkeep.property.integration.test.ts | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts index 8e1b23172..aaca09eaf 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts @@ -81,9 +81,27 @@ const scenario = fc.array(call, { minLength: 1, maxLength: 12 }).chain((calls) = ), batchOf: each(fc.nat(4)), monthly: each(fc.boolean()), + // A call an earlier version kept that a transcript read again offers once more. + alsoIngested: each(fc.boolean()), + tornMonth: fc.boolean(), + strayDay: fc.boolean(), }); }); +/** A line that is not a record, as a crash or a hand edit leaves one. */ +const TORN = '{"tool":"claude-code","key":'; +/** A day no anchor's window reaches, so only a report ever reads it. */ +const STRAY_DAY = "2024-06-15.jsonl"; + +/** The ledger one ingest of these calls would leave: each call once, in canonical order. */ +function canonical(calls: readonly StoredUsage[]): Record { + const files: Record = {}; + for (const [day, inDay] of partitionByDay(foldUsage(calls))) { + files[`${day}.jsonl`] = `${inDay.map((record) => JSON.stringify(record)).join("\n")}\n`; + } + return files; +} + /** What an earlier version would have written for these calls: one file per month. */ function monthsOf(records: readonly StoredUsage[]): Map { const months = new Map(); @@ -114,23 +132,43 @@ async function bytes(dir: string): Promise> { } describe("the ledger's bytes, once a report has run", () => { - it("are those of one ingest of the same calls, whatever the order, grouping and starting format", async () => { + it("are those of one ingest of the same calls, whatever the order, grouping, starting format and damage", async () => { await fc.assert( - fc.asyncProperty(scenario, async ({ calls, order, batchOf, monthly }) => { - const earlier = calls.filter((_, index) => monthly[index]); - const { dir, ledger } = await freshLedger(monthsOf(earlier)); - const batches: StoredUsage[][] = [[], [], [], [], []]; - for (const index of order) { - if (!monthly[index]) batches[batchOf[index] as number]?.push(calls[index] as StoredUsage); - } - for (const batch of batches) if (batch.length > 0) await storeCalls(ledger, batch); - await settleLedger(ledger); + fc.asyncProperty( + scenario, + async ({ calls, order, batchOf, monthly, alsoIngested, tornMonth, strayDay }) => { + const earlier = calls.filter((_, index) => monthly[index]); + const files = monthsOf(earlier); + const firstMonth = [...files.keys()][0]; + if (tornMonth && firstMonth !== undefined) { + files.set(firstMonth, `${files.get(firstMonth)}${TORN}\n`); + } + if (strayDay) files.set(STRAY_DAY, `${TORN}\n`); + const { dir, ledger } = await freshLedger(files); + const batches: StoredUsage[][] = [[], [], [], [], []]; + for (const index of order) { + if (!monthly[index] || alsoIngested[index]) { + batches[batchOf[index] as number]?.push(calls[index] as StoredUsage); + } + } + for (const batch of batches) if (batch.length > 0) await storeCalls(ledger, batch); + await settleLedger(ledger); + + const settled = await bytes(dir); + expect(settled).toEqual(canonical(calls)); + const lines = Object.values(settled).flatMap((text) => text.split("\n").slice(0, -1)); + expect(lines).toHaveLength(new Set(calls.map((c) => `${c.tool}\u0000${c.key}`)).size); + const reference = await freshLedger(); + await storeCalls(reference.ledger, calls); + await settleLedger(reference.ledger); + expect(settled).toEqual(await bytes(reference.dir)); - const reference = await freshLedger(); - await storeCalls(reference.ledger, calls); - await settleLedger(reference.ledger); - expect(await bytes(dir)).toEqual(await bytes(reference.dir)); - }) + const again = await settleLedger(ledger); + expect([again.skippedLines, again.damaged.size, again.unsettled.size]).toEqual([0, 0, 0]); + expect(await bytes(dir)).toEqual(settled); + } + ), + { numRuns: 200 } ); - }, 120_000); + }, 240_000); }); From 7ff1deea96ac4d8d773875e41b3cd8ef79f98760 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:18:13 +0200 Subject: [PATCH 15/17] refactor(telemetry): a day's year bound has no check a test cannot break The four-digit check on the computed day was a ten-character regex on a ten-character slice, so its anchors were equivalent mutants; it is now a sign check. Reading a stored time goes through dayOf alone. Tests pin the leading anchor and a zoned time naming no real day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/src/contexts/telemetry/domain/stored-usage.ts | 7 +++---- .../contexts/telemetry/domain/stored-usage.unit.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index 7077efed6..8adcb21ab 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -23,7 +23,6 @@ const COUNTERS = [ /** A time that names its own zone and a four-digit year: anything else would be read in the * machine's zone, or give a day no partition name can hold. */ const ZONED = /^\d{4}-\d{2}-\d{2}T[^Z+-]*(Z|[+-]\d{2}:\d{2})$/; -const FOUR_DIGIT_DAY = /^\d{4}-\d{2}-\d{2}$/; /** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone, or * `null` for a time with no zone, or whose UTC day falls outside four-digit years. */ @@ -31,8 +30,9 @@ export function dayOf(at: string): string | null { if (!ZONED.test(at)) return null; const instant = Date.parse(at); if (Number.isNaN(instant)) return null; + // An offset can carry the instant past year 9999 or before year 0, written `+YYYYYY`/`-YYYYYY`. const day = new Date(instant).toISOString().slice(0, 10); - return FOUR_DIGIT_DAY.test(day) ? day : null; + return day.startsWith("+") || day.startsWith("-") ? null : day; } const DAY_MS = 24 * 60 * 60 * 1000; @@ -136,8 +136,7 @@ function isStoredUsage( (USAGE_AGENTS as readonly unknown[]).includes(object.agent) && isText(object.key) && isText(object.session_id) && - typeof object.at === "string" && - dayOf(object.at) !== null && + dayOf(String(object.at)) !== null && isText(object.repository_id) && isNullableText(object.tool_version) && isNullableText(object.model) && diff --git a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts index fac5b3d19..9572bbed3 100644 --- a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts @@ -61,6 +61,11 @@ describe("a record belongs to the day of its own time, in UTC", () => { expect(dayOf("10000-01-01T00:00:00Z")).toBeNull(); expect(dayOf("9999-12-31T23:30:00-02:00")).toBeNull(); expect(dayOf("0000-01-01T00:30:00+02:00")).toBeNull(); + expect(dayOf("+002026-10-15T00:00:00Z")).toBeNull(); + }); + + it("has none for a zoned time that names no real day", () => { + expect(dayOf("2026-13-45T00:00:00Z")).toBeNull(); }); it("keeps the last and first day a four-digit year has", () => { From 9a13a7348a4089a697eabc1e6100ff3ea28da439 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:31:00 +0200 Subject: [PATCH 16/17] fix(telemetry): a stored time that is not text is refused again 7ff1deea read a stored time through dayOf(String(at)), so a time held in a one-element array coerced to a zoned string and was accepted with a non-text at. The text check is restored, superseding that commit's "goes through dayOf alone"; the new refusal case also kills its mutant, so the check is no longer equivalent. The zone pattern's closing anchor is kept and says why no test can observe it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/src/contexts/telemetry/domain/stored-usage.ts | 6 ++++-- .../contexts/telemetry/domain/stored-usage.unit.test.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index 8adcb21ab..f0a48e21a 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -21,7 +21,8 @@ const COUNTERS = [ ] as const; /** A time that names its own zone and a four-digit year: anything else would be read in the - * machine's zone, or give a day no partition name can hold. */ + * machine's zone, or give a day no partition name can hold. The closing anchor states the + * whole shape; V8 already rejects any text after an ISO time, so no test can observe it. */ const ZONED = /^\d{4}-\d{2}-\d{2}T[^Z+-]*(Z|[+-]\d{2}:\d{2})$/; /** `YYYY-MM-DD` in UTC, so a ledger partition does not depend on the machine's time zone, or @@ -136,7 +137,8 @@ function isStoredUsage( (USAGE_AGENTS as readonly unknown[]).includes(object.agent) && isText(object.key) && isText(object.session_id) && - dayOf(String(object.at)) !== null && + typeof object.at === "string" && + dayOf(object.at) !== null && isText(object.repository_id) && isNullableText(object.tool_version) && isNullableText(object.model) && diff --git a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts index 9572bbed3..29abdbd15 100644 --- a/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts @@ -277,6 +277,7 @@ describe("a stored line is read back or refused", () => { ["a numeric repository", JSON.stringify({ ...stored(), repository_id: 4 })], ["a numeric key", JSON.stringify({ ...stored(), key: 4 })], ["a numeric time", JSON.stringify({ ...stored(), at: 4 })], + ["a time in an array", JSON.stringify({ ...stored(), at: ["2026-10-07T10:00:00.000Z"] })], ["a time with no zone", JSON.stringify({ ...stored(), at: "2026-10-15T22:00:00" })], ["a time past year 9999", JSON.stringify({ ...stored(), at: "+010000-01-01T00:00:00Z" })], ["a numeric session", JSON.stringify({ ...stored(), session_id: 4 })], From c0ae14cf074aa4ed7318c052237a3e5ad2400fdb Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 11:46:44 +0200 Subject: [PATCH 17/17] docs(telemetry): say when a time is usable, and keep the review rounds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 2 +- .../2026_10_15_ledger-by-day/contract.md | 2 +- .../2026_10_15_ledger-by-day/review.md | 84 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/review.md diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 3662c1b39..5999dc419 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -76,7 +76,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | `consent-closed` | its live key names a closed interval; says "run `aidd telemetry on`", never says the clone has not opted in | | `unreadable-consent` | git config or `.git` unreadable, directory or git dir the file system will not let ingest look at, clone with no identity, damaged consent log | | `no-cwd` | no working directory | -| `undated` | timestamp not a readable date | +| `undated` | timestamp not a readable date, with no zone (`Z` or `±hh:mm`), or outside four-digit years | ## Report envelope diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md index 270f0d897..0f5413c82 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md @@ -47,7 +47,7 @@ A key whose snapshots were more than a day apart would be outside the window. In ## Contract -- **Format.** `ledger/.jsonl`, one `StoredUsage` per line, by the UTC day of `at`, in time then key order. A record whose `at` does not parse is left out, as today. +- **Format.** `ledger/.jsonl`, one `StoredUsage` per line, by the UTC day of `at`, in time then key order. An `at` is usable only when it parses, carries a zone (`Z` or `±hh:mm`) and falls in a four-digit year, so its day never depends on the machine's time zone. An incoming call without one is counted `undated`; a stored line without one is dropped as damage. - **Ingest.** It loads the partitions of its records' days and the days on each side, folds only those with the incoming records, and rewrites only the days that changed. Its cost does not grow with the ledger's size. - **Report.** It reads every partition, monthly and daily, and folds them. When a call sits in more than one partition, a monthly partition remains, or a partition holds a line that is not a record, it rewrites those partitions in canonical daily form, under the ledger lock. - **Migration.** A monthly `.jsonl` is still read. The first ingest whose window touches a month splits it into its days, then removes it. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/review.md b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/review.md new file mode 100644 index 000000000..121077f9e --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/review.md @@ -0,0 +1,84 @@ +# Review: Ledger by day + +- Rounds: 2 +- Started: 2026-10-15 + +## Round 1 · r-b572 + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `0a103e67...9b0f11c5` +- Axes: code, functional, relevancy +- Verdict: changes-requested +- Score: 15/20 met, 0 unmet, 5 unverified at runtime, 0 out of the diff + +### Criteria + +- [x] P1.1 A "before" timing for one call into a 265k-call month is recorded — `contract.md:15-22`. The recorded figure does not match the bench it cites: see Findings +- Unverified at runtime: P1.2 Each new phase-1 test was seen failing before implementation — no red run recorded beside the plan +- [x] P1.3 A call's day is its UTC day, the window holds both neighbours across month and year, a day's records are in time then key order — `cli/src/contexts/telemetry/domain/stored-usage.ts:24-44,89-108`, `stored-usage.unit.test.ts` +- Unverified at runtime: P2.1 Every adapter case was seen red on the monthly adapter — no red run recorded +- [x] P2.2 One `YYYY-MM-DD.jsonl` per UTC day, canonical order, unchanged day keeps its inode — `usage-ledger-adapter.ts:83-93`, `usage-ledger-adapter.integration.test.ts:90-133` +- [x] P2.3 Ingest reads only its window plus touched monthly files, writes only changed, damaged or unsettled partitions — `ledger-upkeep.ts:13-23`, `usage-ledger-adapter.ts:127-141`; bench rerun on head with the real `PrivateStorageAdapter`: 58-64 ms per call into 265k records +- [x] P2.4 A touched monthly file is split then removed; after a crash in between, the next ingest keeps the better snapshot and finishes the split — `usage-ledger-adapter.ts:93-103`, `usage-ledger-adapter.integration.test.ts:353,372`; checker run: 2,000 generated cases, every intermediate disk state (~7,100 states), no call lost, fold unchanged, convergence to canonical bytes; the same harness fails when monthly removal is moved first +- [x] P2.5 A non-record line is skipped, counted and dropped when its daily or monthly partition is rewritten — `usage-ledger-adapter.ts:66-70`, `usage-ledger-adapter.integration.test.ts:234,386` +- [x] P2.6 `added` and `updated` count within the window — `stored-usage.ts:61-83` +- Unverified at runtime: P3.1 The three phase-3 files were seen red before `settleLedger` existed — no red run recorded +- [x] P3.2 Byte-identical partitions whatever ingest order, grouping and starting format, once a report ran — `ledger-upkeep.property.integration.test.ts`; checker run on head: 5 seeds x 1,000 runs, extended with damaged lines and monthly calls re-ingested, all canonical and a second settle a no-op; fails when settle is skipped +- [x] P3.3 No report total changes across migration or a crash at any point, and the next ingest or report finishes the split — `ledger-upkeep.integration.test.ts:112-153`; checker crash run above +- [x] P3.4 A report rewrites only shared-call, monthly (and, amended, damaged) partitions, under the ledger lock, and nothing on a settled ledger — `report-usage-use-case.ts:61`, `ledger-upkeep.ts:29-34`, `report-usage-use-case.unit.test.ts:107-135` +- [x] P4.1 `forget` removes daily and monthly partitions alike as the ledger — `measurement-erasure-adapter.integration.test.ts:69-82` +- Unverified at runtime: P4.2 The forget guard fails under its named mutation — no mutation record for it +- [x] P4.3 No reader, page or code map names a partition by month, bar the migration reader — grep of `cli/src`, `plugins/aidd-telemetry`, `scripts`, `docs`, `aidd_docs/product`, `cli/aidd_docs`; `usage-contract.md:114` +- [x] P5.1 One call into a 265k-call month under 1 s, before and after from the same bench — checker bench on head: after 58-64 ms, one-time split 1.6-1.7 s; before bench 1.65 s +- [x] P5.2 Fold rule unchanged: suite passes, end-to-end checker exact in both modes — checker run on head: `pnpm typecheck` clean, `pnpm test` 6486 passed / 1 skipped, `check-claude-tokens-end-to-end.cjs` both modes "2 calls, 393 tokens, all on task demo, ticket T-1", bundle 682.2 / 695 KB, biome clean +- Unverified at runtime: P5.3 Telemetry mutation 96 or more — `cli/reports/mutation/telemetry/mutation.json` (96.10) was written 10:41, before `316adb8f` (10:44) changed `usage-ledger-adapter.ts:75`; recorded on another head +- [x] P5.4 A fresh checker finds no partition reader or page left on months — this round's grep + +### Findings + +- `cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts:31-34` : the class doc states the old invariants as still unconditional: "Each call is therefore on disk once", "the bytes depend on what the ledger holds and not on how it got there", "An ingest reads and writes only the days around its calls". The design now allows a call in two days until the next report (contract "The case never observed"), in a monthly and a daily file after a crashed split, and an ingest reads a whole month while splitting it → scope each claim: once on disk and order-independent bytes hold after a report; an ingest reads its window plus any month it splits — 🟡 major, code-health +- `cli/src/contexts/telemetry/application/ledger-upkeep.ts:13` : "Nothing outside the window is read" is false while a monthly file is split: `inWindow` reads every day of that month (`usage-ledger-adapter.ts:127-141`) → say "nothing outside the window, or a month it splits" — 🟢 minor, code-health +- `cli/src/contexts/telemetry/domain/ports/usage-ledger.ts:29-30` : "a month has each of its days written that `records` hold or that is on disk": a day on disk that `records` no longer hold is removed, not written (`usage-ledger-adapter.ts:94-96`) → "each of its days written from `records`, or removed when they hold none" — 🟢 minor, code-health +- `cli/src/contexts/telemetry/domain/stored-usage.ts:23-27` : "so a ledger partition does not depend on the machine's time zone" holds only for an `at` with a zone; `at` is the raw transcript text (`claude-transcript-usage.ts:89`) and `parseStoredUsage` checks only that it is text. Checker run: `TZ=America/New_York` gives day `2026-10-16` for `2026-10-15T22:00:00`. A year past 9999 gives `+010000-01`, a file name `DAILY` never reads back, so the record is written and silently lost. Both inherited from `monthOf`, unreachable from today's Claude timestamps → `dayOf` returns `null` unless `at` carries a zone and a four-digit year, so such a call is skipped as undated instead — 🟢 minor, error-handling +- `aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md:15-22` : the "before" table (load 2.8 s, upsert 2.7 s, save 6.0 s, ~11.5 s) is not what the bench reproduces on `next` code: 0.41 s, 0.37 s, 0.87 s, ~1.65 s. Criterion #1 holds either way → record the bench's own figures, or name the setup that gave 11.5 s — 🟢 minor, fit +- `cli/tests/contexts/telemetry/application/ledger-upkeep.property.integration.test.ts:128-145` : the shipped property never re-ingests a call that already sits in a monthly file, never damages a line, runs at the default 100 cases, and compares only to a reference ledger, so a defect that drops the same call on both sides passes. The checker's extended version found no defect → add a re-ingest-overlap flag and a damaged line to the scenario, and also assert the bytes equal `partitionByDay(foldUsage(calls))` — 🟢 minor, code-health +- `cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts:131` : `.filter((name) => MONTHLY.test(name))` is redundant, since `touched` holds only `YYYY-MM` keys and a daily name never matches one. It is the equivalent mutant that survives → drop it, or keep it and say why — 🟢 minor, rot +- `cli/src/presentation/display/telemetry/telemetry-report-display.ts:128` : the report says damaged lines were "skipped", but the report now also drops them (`settleLedger`), so the next report no longer shows them. The ingest display already says "dropped" → align the wording — 🟢 minor, fit + +## Round 2 · r-3a5f + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `0a103e67...9a13a734` +- Axes: code, functional, relevancy +- Verdict: approve +- Score: 16/20 met, 0 unmet, 4 unverified at runtime, 0 out of the diff + +### Criteria + +- [x] P1.1 A "before" timing for one call into a 265k-call month is recorded — `contract.md:15-21`, with the re-measured bench figures beside the original +- Unverified at runtime: P1.2 Each new phase-1 test was seen failing before implementation — no red run recorded beside the plan +- [x] P1.3 A call's day is its UTC day, the window holds both neighbours across month and year, a day's records are in time then key order — `cli/src/contexts/telemetry/domain/stored-usage.ts:23-55,95-117`, `stored-usage.unit.test.ts:52-79` +- Unverified at runtime: P2.1 Every adapter case was seen red on the monthly adapter — no red run recorded +- [x] P2.2 One `YYYY-MM-DD.jsonl` per UTC day, canonical order, unchanged day keeps its inode — `usage-ledger-adapter.ts:86-99`, `usage-ledger-adapter.integration.test.ts:90-133` +- [x] P2.3 Ingest reads only its window plus touched monthly files, writes only changed, damaged or unsettled partitions — `ledger-upkeep.ts:11-24`, `usage-ledger-adapter.ts:130-143` +- [x] P2.4 A touched monthly file is split then removed; after a crash in between, the next ingest keeps the better snapshot and finishes the split — `usage-ledger-adapter.ts:97-106`; checker crash harness rerun on head: 800 cases, 3,617 intermediate states, half-split starts included, no call lost, fold unchanged, converges +- [x] P2.5 A non-record line is skipped, counted and dropped when its daily or monthly partition is rewritten — `usage-ledger-adapter.ts:66-70`, `usage-ledger-adapter.integration.test.ts:234,386` +- [x] P2.6 `added` and `updated` count within the window — `stored-usage.ts:73-93` +- Unverified at runtime: P3.1 The three phase-3 files were seen red before `settleLedger` existed — no red run recorded +- [x] P3.2 Byte-identical partitions whatever ingest order, grouping and starting format, once a report ran — `ledger-upkeep.property.integration.test.ts` now covers re-ingest of monthly calls, a torn monthly line, a stray damaged day, asserts canonical bytes, one line per call and an idempotent second settle, at 200 runs; passed 3 times on head; checker's extended property: 5 seeds x 1,000 runs, all canonical +- [x] P3.3 No report total changes across migration or a crash at any point, and the next ingest or report finishes the split — `ledger-upkeep.integration.test.ts:112-153`; checker crash harness above +- [x] P3.4 A report rewrites only shared-call, monthly and damaged partitions, under the ledger lock, and nothing on a settled ledger — `report-usage-use-case.ts:61`, `ledger-upkeep.ts:30-35` +- [x] P4.1 `forget` removes daily and monthly partitions alike as the ledger — `measurement-erasure-adapter.integration.test.ts:69-82` +- Unverified at runtime: P4.2 The forget guard fails under its named mutation — no mutation record for it +- [x] P4.3 No reader, page or code map names a partition by month, bar the migration reader — grep unchanged since round 1; delta adds no partition reader +- [x] P5.1 One call into a 265k-call month under 1 s, before and after from the same bench — round 1 bench, `contract.md:19`; the delta does not touch the read or write path's cost +- [x] P5.2 Fold rule unchanged: suite passes, end-to-end checker exact in both modes — checker run on head: `pnpm typecheck` clean, `vitest run tests/contexts/telemetry tests/presentation tests/e2e/telemetry-ingest.e2e.test.ts` 1714 passed; executor's full suite 6495 passed and e2e both modes not rerun by the checker +- [x] P5.3 Telemetry mutation 96 or more — `cli/reports/mutation/telemetry/mutation.json`, written 2026-10-15T09:43:32Z, after the last `cli/src` commit `9a13a734` (09:31:00Z): 3735 killed, 135 survived, 15 no coverage, 96.14; new survivor `stored-usage.ts:26` (closing `$`) is equivalent, V8 returns `NaN` for any text after an ISO time (checked: `"…Zjunk"`, `"…+02:00x"`, `"…Z "`) +- [x] P5.4 A fresh checker finds no partition reader or page left on months — this round + +### Findings + +- `aidd_docs/product/usage-contract.md:79` : `undated` reads "timestamp not a readable date", but `dayOf` (`stored-usage.ts:26-37`) now also refuses a readable time with no zone or outside four-digit years, so a call with `2026-10-15T22:00:00` is counted `undated` though it is readable → "timestamp not a readable date with a zone, in a four-digit year" — 🟢 minor, fit +- `aidd_docs/tasks/2026_10/2026_10_15_ledger-by-day/contract.md:50` : "A record whose `at` does not parse is left out, as today" no longer describes the rule, and the same narrowing makes `parseStoredUsage` (`stored-usage.ts:140-141`) refuse such a stored line, counted and dropped as "not a record". A scan of 1,831,005 local Claude `timestamp` values found none without `Z`, so no stored record is lost in practice → state the zone and four-digit-year rule, and that a stored line breaking it is dropped as damage — 🟢 minor, fit