diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index cb6f72b0e..48e432e47 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -56,7 +56,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Rule | Value | | --- | --- | -| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId:timestamp`; each advisor iteration adds `#advisor` | +| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call; nothing bounds how far apart its copies are written, so an ingest storing such a call reads every day, and the ledger holds it once, except copies an older CLI stores after the migration, which only a report with no period folds); each advisor iteration adds `#advisor` | | Fold | a call is written several times (streamed snapshots, resumed sessions, sub-agents); one record per `(tool, key)`: largest sum of the four counters (`null` as `0` for this comparison only) | | Tie | earliest `at`, then lowest `session_id`, then serialised record. No input order changes the winner | | Dropped | model `` | @@ -64,7 +64,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Advisor | each `advisor_message` iteration is a record, `agent: advisor`, own model | | Sub-agent | `isSidechain` or an `agentId` | -**Repository.** The call's working directory resolved at ingest: its git root, named by the hash of the `origin` address (`host/owner/repo`), else the root commit. The address is never kept. +**Repository.** The call's working directory resolved at ingest: its git root, named by the hash of the `origin` address (`host/owner/repo`), else the root commit. The address is never kept. Every form (https, `ssh://`, `git://`, scp-like) gives one address: scheme, credentials, port, `.git` and trailing `/` dropped, host case-folded, `ssh.github.com` read as `github.com`. Owner and repo are case-folded on `github.com` only, which documents them as not case sensitive; any other host keeps their case, so two spellings there may still give two ids. **Not stored.** Storing follows [Consent](#consent). A call read and left out counts in `run.not_stored` of the report that read it, under one reason, and nothing of it is kept, except an `undated` call of a clone that consents (see [Left out](#left-out)): @@ -124,7 +124,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 2, "refused": "AIDD_TELEMETRY=0"}`. - The text report labels an `absent` person `anonymous`; the envelope keeps `kind: absent`, `key: null`. - A report ingests first; what that read found is its `run`, see [the envelope reference](../../plugins/aidd-telemetry/skills/01-usage/references/envelope.md#run). `coverage.records` describes the period; `coverage.left_out` every ingest so far. -- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. Outside it, only a report with no period folds every copy of a call. `run.skipped_ledger_lines` counts the lines of the partitions read. +- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of a call with a `requestId` have been observed less than a day apart, and under that bound both print the same figures; a call with none is held once by the ingest that stores it, so no bound is needed. Outside these, only a report with no period folds every copy of a call: copies an older CLI stores after the migration are one such case. `run.skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. ### Filters @@ -161,15 +161,16 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | Store | Writer | Readers | Holds | | --- | --- | --- | --- | -| `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 it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | +| `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 (every day, and so every monthly file, when one has no `requestId`), and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | | `ledger/left-out.txt` | `ingest` | `report`, `forget` | what ingests measured and could not store, by hash, see [Left out](#left-out). cli-v5.5.0 never reads it | +| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases, pending}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made from each root `roots.json` names, by its `origin` now. Every read re-keys a Claude Code call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. A root that could not be read, or was not there, stays in `pending`: every later ingest looks at those roots alone and, when one adds an id, rewrites the stores again. Until then its stored calls keep the earlier id while its new calls get the id of now: two ids for one repository. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. A requestless call it stores then may sit in two days: a report with a period may count that copy, until a report with no period settles the ledger. `branches.jsonl` is never rewritten, so it relies on this table for good: a damaged table is made again from the clones still there, and a snapshot of a repository none of whose clones remains keeps its earlier id, so the calls of now no longer find its declaration. An `undated` hash in `left-out.txt` keeps its earlier key | | `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`, for every directory a read call or line named, in any clone. 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 | | `ledger/.lock` | CLI | CLI | `{pid, created_at}`; dead process or older than ten minutes: cleared | | `bindings/.lock` | CLI | CLI | same format. Declarations and snapshots run under it; `task` never takes the ledger lock, which an ingest holds while reading transcripts | | `bindings/sessions.jsonl` | `task` | hooks, `report`, `task` | `{session_id, task, ticket, none, declared_at, by}`, `by` = `command` \| `hook-intercept` | -| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, from its reflog | +| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, from its reflog. Read with `repository-ids.json` applied, never rewritten | | `bindings/carries.jsonl` | `SessionStart` hook | hooks, `report` | `{session_id, from, at}` | | `bindings/processes.jsonl` | `SessionStart` hook | `SessionStart` hook | `{pid, session_id, source, at}` | | `identity.json` | `identity`, `on` | `report`, `status`, `on` | `{"person_id": ""}`, owner-only | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md new file mode 100644 index 000000000..8b4c8bc6f --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md @@ -0,0 +1,104 @@ +--- +status: done +--- + +# One call, one key; one repository, one id + +## Need + +Found by the R1 telemetry audit. Maintainer decision: migrate the stored data, the measurement is a beta. + +| # | Hole | Where | +| --- | --- | --- | +| M7 | One repository gets several ids. The owner and repository keep their case on every host, and an `ssh://` or `git://` host keeps its case too (WHATWG `URL` folds only special schemes): `git@github.com:Example/X.git`, `ssh://git@GitHub.com/example/x` and `https://github.com/example/x` give three ids. | `domain/repository-identity.ts` | +| M10 | A call with no `requestId` is keyed `message.id:sessionId:timestamp`. Its copies carry different timestamps, so they are never folded and the call is counted once per copy. | `domain/formats/claude-transcript-usage.ts` | + +## Rules, before and after + +| Rule | Before (cli-v5.5.0 to `616dbbea`) | After | +| --- | --- | --- | +| Scheme | dropped | dropped | +| Userinfo | dropped | dropped | +| Port | dropped (a scp-like address has none) | dropped | +| Host | case-folded for `http(s)`, `ws(s)`, `ftp` and scp-like; kept for `ssh://`, `git://`, `git+ssh://` | case-folded on every form ([RFC 3986 §3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2): the host is case-insensitive) | +| `ssh.github.com` | its own host | `github.com`: GitHub's SSH over port 443 answers there for the same repositories ([GitHub docs](https://docs.github.com/en/authentication/troubleshooting-ssh/using-ssh-over-the-https-port)) | +| Owner and repository | case kept | `github.com`: case-folded (GitHub REST: owner and repo, "The name is not case sensitive", [docs](https://docs.github.com/en/rest/repos/repos#get-a-repository)). Any other host, GitHub Enterprise included: case kept, since no primary source says otherwise there; two spellings may then still give two ids, never one id for two repositories | +| `.git` suffix, leading and trailing `/` | dropped | dropped, after case-folding where the host folds it | +| No origin | root commit | root commit | +| Key, with `requestId` | `message.id:requestId` | unchanged | +| Key, without `requestId` | `message.id:sessionId:timestamp` | `message.id:sessionId` | +| Advisor iteration | key + `#advisor` | unchanged | + +**Why `message.id:sessionId` is one key per call.** The Messages API: `id` is a "Unique object identifier" of the message ([reference](https://platform.claude.com/docs/en/api/messages)), so no two calls share a `message.id`, and none share it with a session added. The session stays in the key: dropping it, or `requestId`, would re-key every stored call and every `undated` hash, which this fix does not take on (see Limits). + +## Store inventory + +Everything under the telemetry directory, and what this fix does to it. + +| Store | Holds an id or a key | Migration | +| --- | --- | --- | +| `ledger/.jsonl` | `repository_id`, `key` | every read re-keys and re-ids; the first run rewrites them all, folded | +| `ledger/.jsonl` (earlier layout) | `repository_id`, `key` | same; split into days by that rewrite, as any full settle does | +| `ledger/left-out.txt` | `undated ` = hash of tool and key | none: the hash cannot be undone (see Limits). `unrecognised` hashes a line's text: no id, no key | +| `ledger/roots.json` | `repository_id` per directory and clone | every read re-ids; the first run rewrites it | +| `ledger/repository-ids.json` | earlier id to id, both hashes | new: the migration's journal and alias table | +| `ledger/offsets.json` | transcript path to position | none | +| `ledger/consents.jsonl` | token, clone, time | none | +| `ledger/.lock`, `bindings/.lock` | pid, time | none | +| `bindings/branches.jsonl` | `repository_id` per snapshot | every read re-ids; never rewritten, it stays append-only | +| `bindings/sessions.jsonl`, `carries.jsonl`, `processes.jsonl` | session ids | none | +| `identity.json` | person id | none | +| git config `aidd.telemetry`, `branch..aidd*` | token, task | none | +| plugin hooks (`plugins/aidd-telemetry/hooks/lib/`) | compute no repository id and no key | none | + +## Migration + +```mermaid +flowchart TD + S["ingest or report, under the ledger lock"] --> J{"repository-ids.json complete?"} + J -->|"yes, none pending"| I["ingest as usual"] + J -->|"yes, roots pending"| P["locate the pending roots alone"] + P -->|"no new id"| I + P -->|"a new id"| W1 + J -->|absent| M["locate each root roots.json names: earlier id to id, from its origin now"] + M --> W1["write repository-ids.json, not complete"] + J -->|not complete| R + W1 --> R["rewrite roots.json and every ledger partition: re-keyed, re-ided, folded"] + R --> W2["write repository-ids.json, complete"] + W2 --> I +``` + +| Requirement | How | +| --- | --- | +| Automatic | first step of every ingest, so of every report and of the plugin's hook | +| Deterministic | the alias table is a function of each root's `origin` now, written before anything else; a rerun reuses it | +| Idempotent | a key re-keys at most once (the new shape never ends in its own `:at`); an alias never names an id that is itself aliased: an earlier address differs from today's only by an upper-case letter or `ssh.github.com`, which no address of today holds | +| Crash-safe | under the ledger lock; every write is the existing write-then-rename; the table is written first and marked complete last, so a run stopped at any step leaves it incomplete and the next run redoes the whole rewrite. Exercised: a failure at every write (`replace`). Reasoned, not exercised: a stop during the removals that follow the writes, and a `kill -9` | +| Read-time | every reader of the ledger, `roots.json` and `branches.jsonl` applies the key rule and the table, so a state the migration has not finished, or lines an older CLI writes afterwards, still count each call once under one id in a report with no period; a bounded report may count a requestless copy an older CLI stored after the migration (see Limits) | +| No double count | copies of a call re-keyed alike fold, by the existing fold, to the copy one ingest would have kept | + +**An older CLI on migrated data** (cli-v5.5.0): the lines have the same shape, so it reads and reports them as they are, and ignores `repository-ids.json`. What it ingests afterwards it stores under its own rules; the newer CLI then re-keys it, and re-ids it when its repository is in the table, at every read. A requestless call it stores is not held once: a report with no period settles it. + +## Acceptance + +1. `normaliseRemote` folds every form in the table above to one address, and keeps owner and repo case for any host but `github.com`. +2. A requestless call's copies at different timestamps are one record. +3. For any pre-migration store (mixed-case ids, timestamped keys, copies across days, partitions and the earlier monthly layout), the migrated report and ledger bytes equal those of one fresh ingest under the new rules. +4. Migrating twice equals once; a failure at every write, then a rerun, equals a clean run. +5. The id and key in `roots.json` and the ledger are rewritten on disk by the first run; `branches.jsonl` is aliased at read. +6. Gates: `cd cli && pnpm test`, `node --test 'scripts/__tests__/**/*.test.js'`, `pnpm test:mutation:telemetry` at 96 or more, `pnpm smoke`, the tokens end-to-end checks, bundle within 709 KB. + +## Limits + +| Limit | Effect | +| --- | --- | +| A root unreadable or absent at the migration | kept pending and looked at again by every ingest; until it can be read, its stored calls keep the earlier id and its new calls get the new one: two ids. A clone deleted for good stays pending, at the cost of one look per ingest | +| `repository-ids.json` damaged | made again from the clones still there; `branches.jsonl`, never rewritten, then loses the alias of a repository with no clone left, and its declarations stop matching the calls of now | +| Requestless copies more than a day apart | measured: no requestless line in 512,820 real usage lines (3,837 transcripts); copies of a message with a request id, 184,502 of them, at most 0.27 h apart. Nothing bounds the requestless ones, so storing one reads the whole ledger (measured by the checker at 250,000 records: 1,672 ms, against 13 ms with a request id) and the ledger holds it once | +| Requestless lines an older CLI writes after the migration | keyed by their timestamp, stored in their own day: a report with a period may count such a copy as well as the call's other copy, until a report with no period settles the ledger. Needs a downgrade or a pinned older binary, and no requestless line was measured | +| A root whose `origin` changed since its calls were stored | the table maps the address it has now, so those calls keep their id, as before | +| `undated` hashes in `left-out.txt` from before (unreleased: cli-v5.5.0 has no such file) | cannot be re-keyed; copies of one undated requestless call kept apart stay counted apart, and one read again from the start of its transcript adds a hash beside the old one | +| A requestless call copied into another session | two keys, as before; seen copies across sessions carry a `requestId`, keyed without the session | +| One call seen with and without a `requestId` | two keys, as before | +| A host alias from `~/.ssh/config` (`git@work-github:o/r`) | its own host: nothing on disk says what it stands for | +| Percent-encoding in a URL path | kept as written; a scp-like address is never encoded | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md new file mode 100644 index 000000000..13c4fe62f --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -0,0 +1,340 @@ +# Evidence + +## Red runs, before any production change + +### Phases 1 and 2 (identity and key rules) + +`pnpm exec vitest run` on the identity, legacy and format tests: 15 failed, 92 passed. The two legacy test files failed to load (their modules did not exist yet); their behaviour went red under the hand mutations below. + +```txt +× owner and repo in another case gives one spelling + AssertionError: expected 'github.com/Acme/Widgets' to be 'github.com/acme/widgets' +× ssh:// upper-case host gives one spelling + AssertionError: expected 'GitHub.COM/acme/widgets' to be 'github.com/acme/widgets' +× ssh over the https port gives one spelling + AssertionError: expected 'ssh.github.com/acme/widgets' to be 'github.com/acme/widgets' +× https in another case, upper-case .GIT on github.com, ssh:// with a port, git://, git+ssh://, + ssh over the https port in upper case, credentials and a port: one spelling each +× keeps the case of the path on a host not known to ignore it, whatever the form +× is the sha256 of the normalised remote, whatever its spelling +× keys a requestless call by message id and session id, so its copies are one key +× keys a requestless call's advisor iterations under the same key +× reads an empty request id as none +``` + +### Phase 3 (stored data), against skeletons that compile and migrate nothing + +10 failed: every adapter and migration case, and two of the three properties. + +```txt +× re-keys a requestless call's copies stored apart, re-ids them, and counts the call once + AssertionError: expected [ Array(2) ] to deeply equal [ { tool: 'claude-code', …(15) } ] +× folds a line an earlier version writes after the migration into the call it is a copy of +× name each repository by its id of now +× join an earlier id's snapshots to the history of its id of now, the file untouched +× maps the id an earlier version gave each live root's origin to the id of now, and rewrites roots.json and the ledger +× maps nothing for an origin both rules name alike, nor for a root with no origin +× finishes an unfinished one with its own table, asking git nothing +× writes its table before anything else, and says it is complete last +× reports, and lies on disk, as one fresh ingest of the same copies under the rules of now + Caused by: AssertionError: expected [ …(6) ] to deeply equal [ …(6) ] +× migrated twice is migrated once +``` + +What the properties generate: requestless and requested calls, advisor iterations, copies across midnight and a month end, earlier reads in up to four batches, the monthly layout, three spellings of one GitHub repository, another host and a repository with no origin, every clone live and recorded in `roots.json`. Not generated: undated calls, a clone gone at the migration, an origin changed since its calls were stored (all stated limits). The stop property injects a failure at every `replace`; the removals that follow the writes and a `kill -9` are reasoned, not exercised. + +The third property (stop at any write, then rerun) passed against the skeleton, since a migration that does nothing also does nothing when stopped. It is proven by mutation G-h below. + +## Hand mutations + +Each mutation applied alone to the final code, the named tests run, the file restored: 23 of 23 went red. + +| # | Guard broken | Red tests (first ones) | +| --- | --- | --- | +| M7-a | host case-folded on every form | scp-like upper-case host, ssh:// upper-case host (8 tests) | +| M7-b | `ssh.github.com` is `github.com` | ssh over the https port (2) | +| M7-c | path case-folded on `github.com` only (folded everywhere) | keeps the case of the path on a host not known to ignore it (2) | +| M7-d | path case-folded on `github.com` at all | owner and repo in another case (7) | +| M7-e | the earlier rule folds a scp-like host only | of ssh://git@GitHub.COM:22/... hashes GitHub.COM/... (2) | +| M10-a | requestless key without the timestamp | keys a requestless call by message id and session id (3) | +| K-a | an earlier key is re-keyed | drops the timestamp from a requestless key; the fresh-ingest property | +| K-b | advisor suffix kept | keeps an advisor iteration's suffix | +| K-c | only the call's own session and time dropped | drops only the call's own time, after its own session | +| L-a | the ledger read applies the rules | re-keys a requestless call's copies stored apart (6) | +| L-b | a re-keyed partition is rewritten | maps the id ... and rewrites roots.json and the ledger (3) | +| R-a | `roots.json` read re-ids | name each repository by its id of now (2) | +| S-a | snapshots read re-id | join an earlier id's snapshots; the fresh-ingest property | +| G-a | a complete table asks nothing and rewrites nothing | asks git nothing and rewrites nothing once complete (survived first: the test only checked git; strengthened to check the files) | +| G-b | an unfinished table is reused | finishes an unfinished one with its own table | +| G-c | the table is written first | writes its table before anything else (2) | +| G-d | `roots.json` rewritten | the fresh-ingest property (3) | +| G-e | the ledger rewritten | finishes an unfinished one (3) | +| G-f | the table marked complete | maps nothing ... (4) | +| G-g | only a changed id is mapped (`if (true)`) | maps nothing for an origin both rules name alike, nor for a root with no origin | +| G-i | no `roots.json` made where there was none | leaves no roots.json where there was none | +| G-h | complete only after the rewrite (marked complete at once) | stopped at any write and run again, ends as a run never stopped | +| I-a | ingest migrates first | the fresh-ingest property; migrated twice is migrated once | + +## Gates + +| Gate | Result | +| --- | --- | +| `cd cli && pnpm test`, first full run | 3 failed: `folder-size` (three telemetry folders past ten files), `orchestrator-deps` (ingest's sixth collaborator), `telemetry-ingest.e2e` (the new `repository-ids.json`). Fixed by `legacy/` subfolders, the count with its reason, the expected listing | +| second full run | 3 failed: `codebase-map` (the two new `legacy/` folders), and the fresh-ingest property timing out at 60 s under the full suite's load (a cascade `ENOTEMPTY` in the next test). Fixed by the map, and the 240 s timeout the other telemetry properties use | +| third full run | 518 files passed, 1 skipped; 6,779 tests passed, 1 skipped | +| final full run, after the survivor tests | 519 files passed, 1 skipped; 6,789 tests passed, 1 skipped | +| `node --test 'scripts/__tests__/**/*.test.js'` | first run: 3 failed, `check-markdown-links` (this folder's `phase-4.md` linked `evidence.md` before it existed); after: 460 of 460 passed | +| `pnpm smoke` | PASS 132, FAIL 0, SKIP 1, coverage 100% | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| same, `--attended` | same, `asked, answered by the hook, kept across /clear` | + +## Bundle + +| Step | `dist/cli.js` | +| --- | --- | +| `origin/next` `616dbbea` | 712,722 B (696.0 KB) | +| this change | 716,597 B (699.8 KB), budget 709 KB (726,016 B) not raised | + +## Mutation, `pnpm test:mutation:telemetry` + +| Run | Score (floor 96) | +| --- | --- | +| full, 4,555 mutants | 96.8 | +| incremental, after tests for the survivors on changed lines | 97.2 | + +Survivors of the first run on changed lines, and what became of them: + +| Where | Mutant | Now | +| --- | --- | --- | +| `stored-usage-migration.ts` | `roots.size > 0` to `true` | killed: no `roots.json` is made where there was none | +| `stored-usage-migration.ts` | the null checks around `earlier !== now` | removed: both rules fall back to the root commit alike, so one is null only when both are; `if (earlier !== now)` is killed by a root with neither origin nor commit | +| `stored-usage-migration.ts` | `roots.sort(...)` dropped | removed: the table is a map written in text order, the order roots are located in changes nothing | +| `repository-aliases.ts` | the parse guards | killed: a unit test per damaged shape | +| `legacy-repository-identity.ts` | `address === null \|\| path === null`, `&&` | killed by a remote with a host and one segment | + +Left, on changed lines, both equivalent: + +| Where | Mutant | Why it cannot be killed | +| --- | --- | --- | +| `legacy-repository-identity.ts:9` | `address === null` to `false` | `path` is `null` whenever `address` is, so `path === null` returns the same | +| `binding-snapshot-store-adapter.ts:45` | `?? ""` to `?? "Stryker was here!"` | an absent file then reads as one line that is no snapshot, skipped: no snapshot either way. The line moved from `history()` and `latest()` into `read()` unchanged | + +The other survivors in the touched files (`ingest-usage-use-case.ts` 88, 117, 121, 136; `usage-ledger-adapter.ts` 84, 121, 159; `claude-transcript-usage.ts` 42, 47) are on lines this change does not touch. + +## After merging `next` (#1048): a migrated day kept the order it was found in + +The fresh-ingest property failed in the pre-push run on the merge commit `ffe60df1`, seed `-554519443`. It fails the same way on `247ced3a`, before the merge (temporary worktree, same test): the merge did not cause it. + +Counterexample, pinned as its own test ("puts a day it neither re-keys nor re-ids in the order one ingest writes"): calls 7 and 9 read before the migration in one batch, call 0 after. The earlier store wrote `2026-10-08.jsonl` in key order (`msg_7` at 12:00, then `msg_9` at 00:00:01). Neither call is re-keyed or re-ided, and no later read touches that day. + +```txt +× puts a day it neither re-keys nor re-ids in the order one ingest writes, whatever order it was found in + AssertionError: expected { …(3) } to deeply equal { …(3) } +- "2026-10-08.jsonl": "{…\"key\":\"msg_9:req_9\"…} ++ "2026-10-08.jsonl": "{…\"key\":\"msg_7:req_7\"…} +``` + +**Root cause:** `StoredUsageMigration.execute` called `settleLedger`, which rewrites only the partitions a read finds unsettled (`application/ledger-upkeep.ts` `settleLedger`). `UsageLedgerAdapter.load` marks a day unsettled when a record changes or a call is held twice, never for line order. So a day the rules left alone kept whatever order it had. The contract said the first run rewrites every partition. A released writer always wrote time-then-key order (cli-v5.5.0 `partitionByDay`), so this needs a store no released CLI wrote, such as a hand edit or the test's emulation. It is still a gap between the code and the contract. + +**Fix:** the migration saves every partition from one full load (`ledger.save(records)` with no partition list). `save` leaves a partition that already holds its canonical bytes untouched. + +| Proof | Result | +| --- | --- | +| Pinned test, before the fix | red, above | +| Pinned test and the three properties, after | 4 of 4, in 15 runs with random seeds | +| Mutation G-j: rewrite only the unsettled and damaged partitions again | killed by the pinned test | +| Mutation G-e: no ledger rewrite | killed (3 tests) | + +## Review round 1 (62/100): D1, M1, minors + +### D1: how far apart are a requestless call's copies? Measured first + +Read-only scan of the maintainer's own Claude Code transcripts (script outside the repository; only these aggregates are kept): + +| Measure | Value | +| --- | --- | +| Transcript files, lines | 3,837; 2,147,449 | +| Usage lines (assistant, `message.usage`, not ``) | 512,820 | +| Of which with no `requestId` | 0 | +| Message ids with a request id, written more than once | 184,502 | +| Their widest spread between copies (any session) | 0.2687 h | + +No requestless copy exists to measure, so no spread can be cited for them and the window cannot rely on one. **Decision:** keep M10 (`message.id:sessionId`) and fix it structurally. An ingest that stores a requestless call reads every day, not the days around it (`storeCalls`), so the ledger holds such a call once, whatever its spread, and a bounded read cannot see a copy the full read folds away. The window stays ±1 day for calls with a request id, the measured case. Cost: one full ledger load per ingest that meets a requestless line, which these transcripts never did. + +Red run, before the fix (the probe's case pinned, both generators extended): + +```txt +× finds its copy days away, so the ledger holds the call once and a bounded read agrees + AssertionError: expected [ '2026-10-01.jsonl', …(1) ] to deeply equal [ '2026-10-04.jsonl' ] +× leave every bounded read equal to the full ledger filtered to its period + Counterexample: [[{"anchor":5,"requestless":true,"copies":[{"days":0,…},{"days":3,…,"ingest":1}]}],{"from":null,"to":"2026-11-01"}] + Caused by: AssertionError: expected [ { tool: 'claude-code', …(15) } ] to deeply equal [] +``` + +The new window property generates requestless copies up to five days apart, stored by up to four ingests (requested copies stay within the measured half day). The migration property's times gain copies 3 and 9 days apart, for requestless and requested calls alike. Its run against the unfixed code passed: it compares full reports only, which fold every copy. + +### M1: a root unreadable at the first run + +The table now keeps `pending`: the roots that were `unreadable` or `gone`. A complete table with pending roots makes each later run locate those roots alone. When one adds an id, it rewrites `roots.json` and the ledger again. When none does, it writes nothing, or only the shorter `pending` once a root is no repository any more. + +Red run, before the fix: 10 failed, 15 passed. + +```txt +× maps a root it could not read at its first run once it can, so the repository keeps one id + AssertionError: expected [] to deeply equal [ '/work/a' ] +× keeps a root gone at its first run pending, and drops one that is no repository now +× asks only the pending roots again, and writes nothing while none can be read +× reads back what it wrote, its ids in text order (and the table's other shapes, with `pending`) +``` + +The "drops one that is no repository now" second half was added after the fix. It is proven by mutation M1-g. + +### Minors + +| # | Change | Proof | +| --- | --- | --- | +| m1 | re-keying applies to `claude-code` records only | red before: `× re-keys only what Claude Code keyed so, never another tool's key`; mutation m1 killed | +| m2 | the contract says what a damaged table costs `branches.jsonl` | doc | +| m3 | first-run cost measured, below | no change needed | +| m4 | the migration (`IngestUsageUseCase`) and the alias store (`BindingSnapshotStoreAdapter`) are required. The ledger and `roots.json` adapters build theirs from their own directory, so no wiring can leave them out | `tsc` | +| m5 | `RepositoryIdMigration` reference fixed | - | +| m6 | `period.ts` states both bounds | - | + +### m3: first-run cost against the hook's 110 s + +Probe outside the repository, sandboxed `HOME`, real git: every root is a `git clone --shared` of this repository (1,077 commits), origin `git@github.com:Acme/Tool.git`, so each needs an alias. The ledger has 365 days of records, one in ten with an earlier requestless key. + +| Roots | Records | Ledger | `locate` per root | First run | Second run | Heap | +| --- | --- | --- | --- | --- | --- | --- | +| 50 | 250,000 | 112 MB | 29 ms | 4.5 s | 0 ms | 313 MB | +| 200 | 1,000,000 | 449 MB | 38 ms | 20.8 s | 1 ms | 1.4 GB | + +Both stay well inside 110 s. The 250,000-record case is about half the usage lines found on the measuring machine, before folding. Progress is not split further. + +### Hand mutations, round 2 + +Each applied alone, the named tests run, the file restored: 12 of 12 went red. D1-a to D1-d: the full read, the session test, the advisor suffix, the tool. m1. M1-a to M1-g: unreadable roots pending, gone ones too, a complete table with pending roots looks again, only the pending roots are asked, nothing new writes nothing, a dropped root is saved, `pending` kept in the file. + +## Review round 2 (86/100): n1 to n5 + +| # | Change | Proof | +| --- | --- | --- | +| n1 | Requestless lines an older CLI writes after the migration are a stated limit. A bounded report may count such a copy until a report with no period settles the ledger. Stated in both contracts, with the Key row, the Read-time row and the older-CLI paragraph qualified | the window property now generates them (below) | +| n2 | six stale statements fixed: usage contract (Key row, day-file row, window paragraph), `usage-ledger-adapter.ts` header, CLI memory row, task contract (flowchart with pending roots, the measured 1,672 ms against 13 ms) | doc | +| n3 | one advisor pattern, `ADVISOR` in the Claude format, used by the re-keying too; the redundant guard in `StoredUsageMigration.execute` deleted (a complete table with nothing pending locates nothing and writes nothing) | `#advisor10` and `#advisor3` cases; mutation `\d+` to `\d` killed | +| n4 | the migration property's first run after the upgrade has up to two clones `unreadable` or `gone`, back by the next run; a failure at every write of a pending root's retry, then a rerun, equals a clean retry | mutations P-a and M1-h below | +| n5 | the ledger and `roots.json` adapters keep building their table from their own directory, with one line saying so: no wiring can leave it out | doc | + +Red run, n1: the window property with an older CLI's requestless lines, before the exemption was named: + +```txt +× leave every bounded read equal to the full ledger filtered to its period + Counterexample: [[{"anchor":1,"requestless":true,"copies":[{"days":0,…,"older":false},{"days":2,…,"older":true}]},…],{"from":"2025-12-29","to":"2026-02-28"}] + Caused by: AssertionError: expected [ Array(2) ] to deeply equal [ { tool: 'claude-code', …(15) } ] +``` + +After it, the property exempts the bounded read for the calls an older CLI stored a copy of, and only those. It still asserts one record per call in the full read for every call. + +The n3 two-digit case, the n4 generator and the retry test passed against the code as it stood: the behaviour was right and unpinned. Each is proven by a mutation, all applied alone and killed: + +| # | Mutation | Killed by | +| --- | --- | --- | +| n3 | `ADVISOR` `\d+` to `\d` | keeps an advisor iteration's suffix; is told by its key ending in its own session | +| P-a | an unreadable or gone root not kept pending | the fresh-ingest migration property | +| M1-h | the retry writes its table complete before the rewrite | stopped at any write of a pending root's retry; writes its table before anything else | + +Re-run with them: D1-a to D1-d, m1, M1-a, M1-b, M1-d to M1-g, all killed again (M1-c gone with its guard). + +## Review round 3 (90/100): r1 to r4 + +### r4 and r1: tests, each proven by a mutation applied alone, five runs each + +| # | Test | Mutation | Runs killed | +| --- | --- | --- | --- | +| r4 | a fixed batch mixing a requestless call with one that has a request id; the requestless call's earlier copy is three days away | `incoming.some(isKeyedBySession)` to `every` | 5 of 5 | +| r4 | a table of another version, all else valid, is rejected | the version check dropped | 5 of 5 | +| r4 | a table whose completion is no boolean, all else valid, is rejected | the completion check dropped | 5 of 5 | +| r1 | the window property no longer skips a call an older CLI stored a copy of. It asserts the limit: the bounded read holds the call exactly once when a day of its window holds a copy of it on disk (read off the files, not through the adapter), never otherwise; the full read holds it once | a bounded read drops re-keyed lines (`usage-ledger-adapter.ts:96`) | 5 of 5 | +| r1 | same | every read drops re-keyed lines | 5 of 5 | + +These tests passed against the code as it stood: the behaviour was right, but before them the r4 trigger was pinned only by chance, and the r1 exemption hid an undercount. + +### r3: Stryker on exactly the changed lines, under the lock + +`node scripts/run-mutation.mjs --changed ` mutates the `git diff -U0` ranges of `cli/src` since the merge base. The first runs: + +```txt +--changed 57169330: Report: reports/mutation/changed/ (score 98.8, 31 changed range(s) since 57169330) +--changed origin/next: Report: reports/mutation/changed/ (score 97.2, 44 changed range(s) since origin/next) +``` + +The `origin/next` run left 7 survivors. Two were not equivalent; tests were added for both (commit `8086176b`), each proven by a mutation applied alone: + +| Survivor | Now | +| --- | --- | +| `legacy-usage-key.ts:17` `base.length > earlier.length` to `>=`, and to `true` | killed: a key that is only `:session:at` is no earlier key to strip | +| `usage-ledger-adapter.ts:96` the re-keyed partition no longer marked unsettled (two mutants) | killed: a day holding an older CLI's timestamped key is rewritten when it is settled. The migration's full save had hidden it since `57169330` | + +Final runs, after `8086176b`: + +```txt +--changed 57169330: Instrumented 11 source file(s) with 83 mutant(s); Report: reports/mutation/changed/ (score 98.8, 31 changed range(s) since 57169330) +--changed origin/next: Instrumented 15 source file(s) with 252 mutant(s); Report: reports/mutation/changed/ (score 98.8, 44 changed range(s) since origin/next) +``` + +Survivors left, all equivalent: + +| Survivor | Why it cannot be killed | +| --- | --- | +| `claude-transcript-usage.ts:16` `/#advisor\d+$/` to `/#advisor\d+/` | `#advisor` is only ever written as a key's suffix; message and session ids hold no `#` | +| `repository-aliases.ts:20` `text ?? ""` to `?? "Stryker was here!"` | a missing file then parses as no JSON, and `null` is returned either way | +| `legacy-repository-identity.ts:9` `address === null` to `false` | `path` is `null` whenever `address` is | + +The checker's 95.11 (11 survivors) mutated whole line ranges, including lines this change does not touch, such as `claude-transcript-usage.ts:20-62` and `ingest-usage-use-case.ts:89`. The two figures measure different scopes. + + + +## Review round 4 (94/100): r1b, r5, r6 + +### r1b: a bounded read must keep the full read's copy + +The window property counted the records of a call an older CLI also stored, but never compared them. It now asserts, for each such call whose full-read record lies in the period, that the bounded read's record deep-equals it: its day is then in the window, and the window's fold keeps that same copy. The limit allows over-counting only. + +The checker's mutant, applied alone before `usage-ledger-adapter.ts:96`: + +```ts +if (record !== read && holds !== undefined && heldIn.has(`${record.tool}\u0000${record.key}`)) continue; +``` + +| Test | Runs | +| --- | --- | +| window property as committed in `102baf98` | survived 3 of 3 | +| window property with the content check (`8dee6830`) | killed 3 of 3: `× leave every bounded read equal to the full ledger filtered to its period` | + +The `$` anchor of `ADVISOR` now carries a comment saying it is there for clarity only. + +### Hand mutations, all rounds, counted as mutants + +The 47 counts the hand mutations, each one replacement applied alone; all were killed. Two rows of the round-3 survivor table hold two Stryker mutants each (`>=` and `true`; the two at `:96`), all four killed too. + +| Round | Mutants | +| --- | --- | +| First build | 23 (M7-a to M7-e, M10-a, K-a to K-c, L-a, L-b, R-a, S-a, G-a to G-i, I-a) | +| Canonical day order (`57169330`) | 1 (G-j) | +| Review round 1 | 12 (D1-a to D1-d, m1, M1-a to M1-g) | +| Review round 2 | 3 (`ADVISOR` `\d+` to `\d`, P-a, M1-h) | +| Review round 3 | 7 (mixed batch, version check, completion check, two re-keyed-line drops, `>=` on a bare session key, the unsettled mark) | +| Review round 4 | 1 (r1b) | + +## Gates at the last code commit (`8dee6830`) + +| Gate | Result | +| --- | --- | +| `cd cli && pnpm test` | `Test Files 522 passed | 1 skipped (523)`, `Tests 6813 passed | 1 skipped (6814)` | +| `node --test 'scripts/__tests__/**/*.test.js'` | 461 tests, 461 pass, 0 fail | +| `pnpm build` | `Bundle size: 700.6 KB / budget: 709 KB`; `dist/cli.js` 717,408 B of 726,016 B | +| `pnpm smoke` | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| same, `--attended` | same, then `asked, answered by the hook, kept across /clear` | +| `pnpm test:mutation:telemetry`, full scope, before the review fixes | 97.2 (see above) | +| Stryker, changed lines, after `8086176b` | 98.8 since `57169330` (83 mutants), 98.8 since `origin/next` (252 mutants) | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md new file mode 100644 index 000000000..5ae4226a8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md @@ -0,0 +1,21 @@ +--- +status: done +--- + +# Instruction: The identity rule (M7) + +```txt +cli/src/contexts/telemetry/domain/ +├── repository-identity.ts ✏️ host folded on every form, ssh.github.com, github.com path folded +└── legacy/legacy-repository-identity.ts 🆕 the earlier rule, frozen, for the migration only +cli/tests/contexts/telemetry/domain/ +├── repository-identity.unit.test.ts ✏️ every form of one repository, one address +└── legacy/legacy-repository-identity.unit.test.ts 🆕 the earlier rule pinned +``` + +| Case | Proof | +| --- | --- | +| https, ssh://, git://, git+ssh://, scp-like, userinfo, port, `.git`, trailing `/`, case: one address on `github.com` | unit table | +| another host keeps owner and repo case | unit | +| `ssh.github.com:443` is `github.com` | unit | +| the earlier rule gives what cli-v5.5.0 gave | unit, pinned hashes | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md new file mode 100644 index 000000000..8f3988475 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md @@ -0,0 +1,16 @@ +--- +status: done +--- + +# Instruction: The key rule (M10) + +```txt +cli/src/contexts/telemetry/domain/ +├── formats/claude-transcript-usage.ts ✏️ requestless key message.id:sessionId +└── legacy/legacy-usage-key.ts 🆕 an earlier requestless key, re-keyed +``` + +| Case | Proof | +| --- | --- | +| two copies of a requestless call at two timestamps: one key | format unit test | +| an earlier key re-keyed, advisor suffix kept, a `requestId` key untouched, re-keying twice = once | unit | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md new file mode 100644 index 000000000..d8df26270 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md @@ -0,0 +1,25 @@ +--- +status: done +--- + +# Instruction: Stored data migrated + +```txt +cli/src/contexts/telemetry/ +├── domain/legacy/repository-aliases.ts 🆕 the table's format +├── domain/ports/repository-alias-store.ts 🆕 the table's store +├── infrastructure/legacy/repository-alias-store-adapter.ts 🆕 ledger/repository-ids.json +├── infrastructure/usage-ledger-adapter.ts ✏️ load re-keys and re-ids, marks the partition unsettled +├── infrastructure/resolution-store-adapter.ts ✏️ load re-ids +├── infrastructure/binding-snapshot-store-adapter.ts ✏️ reads re-id +├── application/legacy/stored-usage-migration.ts 🆕 table, rewrite, complete +├── application/ingest-usage-use-case.ts ✏️ migration first, under the lock +└── ../../runtime/wiring/telemetry.ts ✏️ wired +``` + +| Case | Proof | +| --- | --- | +| migrated = fresh ingest, report and ledger bytes | fast-check property | +| twice = once; failure at every write then rerun = clean | same property | +| older CLI's lines after migration still fold | adapter test | +| `branches.jsonl` aliased at read, bytes untouched | adapter test | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md new file mode 100644 index 000000000..b1065e0c2 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md @@ -0,0 +1,12 @@ +--- +status: done +--- + +# Instruction: Docs and gates + +| Edit | Where | +| --- | --- | +| key and repository rules, the new store | `aidd_docs/product/usage-contract.md` | +| where the migration lives | `cli/aidd_docs/memory/telemetry.md` | + +Gates as listed in the contract's acceptance, results in [`evidence.md`](./evidence.md). diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md new file mode 100644 index 000000000..54f43b1ec --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md @@ -0,0 +1,33 @@ +--- +objective: "One call is one key and one repository is one id, in new data and in data already stored." +status: done +--- + +# Plan: One call, one key; one repository, one id + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | M7 and M10 of the R1 telemetry audit closed, stored data migrated, envelope untouched | +| **Source** | [`contract.md`](./contract.md) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | The identity rule (M7) | [`phase-1.md`](./phase-1.md) | +| 2 | The key rule (M10) | [`phase-2.md`](./phase-2.md) | +| 3 | Stored data migrated | [`phase-3.md`](./phase-3.md) | +| 4 | Docs and gates | [`phase-4.md`](./phase-4.md) | + +## Decisions + +| Decision | Why | +| --- | --- | +| Case-fold owner and repo on `github.com` only | the only host with a primary source; elsewhere a split is safer than a merge | +| `message.id:sessionId`, `requestId` keys untouched | the asked fix; re-keying every call would orphan every `undated` hash | +| Read-time rules plus a one-off rewrite | figures are right in any half-migrated state and after an older CLI writes again; the rewrite makes the disk say it too | +| An alias table, not a re-hash | a stored id is a hash: only a live clone's `origin` can say what it was | +| `branches.jsonl` aliased at read, never rewritten | it stays append-only, and needs no second lock | +| Hooks unchanged | they compute no repository id and no key | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md new file mode 100644 index 000000000..f1300298a --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -0,0 +1,103 @@ +--- +status: approved +--- + +# Review: One call, one key; one repository, one id (round 5) + +Head `2632e265`, base `origin/next`. Independent check, read-only on the code. Round 4 (94/100, head `102baf98`) is superseded by this report. + +**Verdict: approve. Score 99/100.** + +No deal breaker. r1b and r5 hold; r6 holds but for its wording (n1). `cli/src` changed only by the `ADVISOR` comment since `429948a3`. Four non-blocking nits remain, all in docs. + +## Deal breakers + +None. + +## Round 4 findings, re-checked + +| # | Claim | Status | Evidence | +| --- | --- | --- | --- | +| r1b | the window property deep-compares the bounded record to the full record of an older-CLI call | fulfilled | `report-window.property.integration.test.ts:405-410`. Round-4 mutant before `usage-ledger-adapter.ts:96`: red 3 of 3, failing at `:409:68` (the new `toEqual`). Own mutant, `found.push(record)` at `:97` to `found.push(record !== read && holds !== undefined ? { ...record, output: Math.max(0, record.output - 1) } : record)` (a bounded read shrinks an older-CLI copy, count unchanged): red 3 of 3, failing at `:409:68` | +| r1b | assertion not flaky | fulfilled | real code, unseeded fast-check (no `seed` or `configureGlobal`), 150 runs per test: green 4 of 4 | +| r5 | every PR number is in evidence.md and matches | fulfilled | see the table below | +| r6 | hand mutations counted as mutants | partial | evidence.md "Hand mutations, all rounds": 23 + 1 + 12 + 3 + 7 + 1 = 47, G-j included. But "each row is one mutant" is false for the r3 survivor table (n1). All its mutants are killed, so wording only | +| - | comment on `ADVISOR`'s `$` | fulfilled | `claude-transcript-usage.ts:15-16`, the only `cli/src` change since `429948a3` | + +### PR description against evidence.md + +| PR figure | evidence.md | +| --- | --- | +| 6,813 passed, 1 skipped | `:333` `Tests 6813 passed \| 1 skipped (6814)` | +| 461 of 461 | `:334` | +| smoke PASS 132, FAIL 0 | `:336` | +| tokens end-to-end, plain and `--attended` | `:337-338` | +| 97.2, floor 96 | `:100-103`, `:339` | +| 98.8 (83 mutants), 98.8 (252 mutants), three equivalent survivors | `:281-282`, survivors table, `:340` | +| 512,820 lines, 184,502 ids, 0.2687 h | D1 section | +| 1,672 ms against 13 ms | `:223`, number only (n2) | +| 4.5 s / 50 roots / 250,000; 20.8 s / 200 / 1,000,000; 110 s | `:203-212` | +| 717,408 B of 726,016 B | `:335`; measured here `dist/cli.js` 717,408 B, built 14:24:56, the head commit is 14:24:55 | +| five days; 3 and 9 days | `:174` | +| 47 mutants | `:318-327` | + +### No regression + +- `git diff --stat 429948a3 HEAD -- cli`: `cli/src` only `claude-transcript-usage.ts | 3 ++-` (comment). Tests since then only add assertions and cases. +- `git diff --stat 102baf98 HEAD`: evidence.md, review.md, the comment, and 6 added test lines. No test weakened. +- Regression set re-run green on the real code (below). + +## Non-blocking nits + +### n1. "Each row is one mutant" vs the round-3 survivor table (Nit) + +- **Where:** evidence.md `:318` and `:326`; the r3 survivor table above them. +- **Issue:** that table has two rows holding two mutants each (`>=` and `true`; "two mutants" at `usage-ledger-adapter.ts:96`). The round-3 count names `>=` and "the unsettled mark" once each, so "each row is one mutant" is false for that table, and 47 counts 2 of its 4. +- **Fix:** say the count is mutants applied by hand, and that `true` and the second `:96` mutant were killed by the Stryker rerun only. Or count all four (49). + +### n2. The 1,672 ms measurement has no record in the tree (Nit, beyond criterion 2) + +- **Where:** PR "the checker measured 1,672 ms at 250,000 records, against 13 ms"; evidence.md `:223` cites the number only. +- **Issue:** the number is in evidence.md and matches, so r5 holds. Its command and output lived in the round-2 review, which every round overwrites. It survives in git history only. +- **Fix:** copy the command and both timings into evidence.md, next to m3. + +### n3. "Gates on the final commit" (Nit) + +- **Where:** PR "How to verify"; evidence.md `:329` says `8dee6830`. +- **Issue:** the gates ran at `8dee6830`, not `2632e265`. `2632e265` changes only evidence.md and review.md (checked), so the figures carry over, but the heading should say so. +- **Fix:** "Gates on the last code commit (`8dee6830`)". + +### n4. The `ADVISOR` comment states an unenforced invariant as fact (Nit) + +- **Where:** `claude-transcript-usage.ts:15-16`, "message and session ids hold no `#`". +- **Issue:** nothing validates it; it holds for Anthropic message ids and Claude Code session UUIDs today. +- **Fix:** "hold no `#` in the formats seen so far", or leave as is. + +## Checklist + +| Item | Result | +| --- | --- | +| No duplication | holds | +| No incoherence | holds, but n1 and n3 (wording) | +| No over-engineering | holds: the r1b fix is 6 test lines | +| No dead code or debug leftovers | holds: one comment in `cli/src`, no logs or TODOs in the test diff | +| Public repo | holds: the caller's local-path grep on `git diff origin/next...HEAD` exits 1; same for the username, the session-id prefix and the scratchpad id, exit 1 | + +## Commands + +All runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `TMPDIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, the file restored with `git checkout --`, lock removed, `git status --short` empty after. + +| Command | Decisive output | +| --- | --- | +| window property, real code, x4 | `Tests 2 passed (2)`, 4 of 4 | +| round-4 mutant before `usage-ledger-adapter.ts:96`; window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`, x3 | `Tests 1 failed \| 87 passed (88)`, `× leave every bounded read equal …`, 3 of 3; stack `:409:68` | +| own shrink mutant at `usage-ledger-adapter.ts:97`, same four files, x3 | `Tests 1 failed \| 87 passed (88)`, 3 of 3; stack `:409:68` | +| `application/legacy`, `domain/legacy`, `ledger-upkeep` (fixed and property), `stored-ids-and-keys`, `usage-ledger-adapter`, `claude-transcript-usage.unit`, `repository-identity` | `Test Files 13 passed (13)`, `Tests 226 passed (226)` | +| `git diff --stat 429948a3 HEAD -- cli` | `cli/src`: `claude-transcript-usage.ts \| 3 ++-` only | +| `stat -f '%z %Sm' cli/dist/cli.js` | `717408 Oct 17 14:24:56 2026` | + +Not run: the full suite (pre-push passed it at `2632e265`: 6,813 tests, per the caller), Stryker, smoke, the tokens end-to-end runs (their records checked for consistency, not reproduced). Nor the ingest, bindings lock, `application/report`, ingest e2e and stream and lock properties round 4 ran: `cli/src` changed only by a comment since then. + +## Score + +99 = 100, minus 1 (n1: r6 partial, the mutant count wording contradicts the table it counts). n2 asks for more than criterion 2; n3 and n4 are wording. No deduction for them. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index adbda98f9..1078b4c55 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -54,6 +54,7 @@ src/ │ │ ├── application/ │ │ │ ├── forget/ # what measurement keeps on a machine, shown and erased │ │ │ ├── identity/ # who, if anyone, the measurement names +│ │ │ ├── legacy/ # bringing what an earlier version stored to today's key and id rules │ │ │ ├── report/ # what the stored calls consumed in a period, along one axis │ │ │ └── switch/ # opting a repository in or out, and leaving it clean of the previous version │ │ ├── domain/ @@ -61,7 +62,7 @@ src/ │ │ │ ├── formats/ │ │ │ ├── consent/ # a clone's identity, and when it consented │ │ │ ├── identity/ # the identifier a person chose to be named by -│ │ │ ├── legacy/ # what the previous version wrote, by name and location +│ │ │ ├── legacy/ # what the previous version wrote, by name and location, and its key and id rules │ │ │ ├── ports/ │ │ │ │ ├── bindings/ # the declarations kept beside the ledger, and the lock their writers share │ │ │ │ ├── forget/ @@ -74,6 +75,7 @@ src/ │ │ ├── declaration/ # where a declaration is kept: git config and the telemetry dir │ │ ├── forget/ # erasing the telemetry dir, the previous version's files, git config keys │ │ ├── identity/ # the identity file in the telemetry dir +│ │ ├── legacy/ # the table of ids an earlier version gave repositories, beside the ledger │ │ ├── left-out/ # what ingests measured and could not store, by hash, beside the ledger │ │ └── switch/ # the project config, what an earlier measurement left in a repository, and Claude settings; the clone's consent in git config is `git-consent-adapter.ts`, beside this folder │ └── translate/ # canonical source to target-native content diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index 33c9be313..616f9382c 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -52,6 +52,7 @@ flowchart LR | What a Claude Code line becomes | `domain/formats/claude-transcript-usage.ts` | | Why a call is counted once | `domain/usage-fold.ts` | | Where a call's repository comes from | `application/directory-resolver.ts`, `domain/repository-identity.ts` | +| How data an earlier version stored is brought to today's key and id rules: read-time in every store reader, rewritten by the first ingest and again when a root left pending adds an id | `application/legacy/stored-usage-migration.ts`, `domain/legacy/` (key, id and table rules), `infrastructure/legacy/repository-alias-store-adapter.ts`; rules in the [usage contract](../../../aidd_docs/product/usage-contract.md#stores) | | Which task a call belongs to | `domain/report/attribution.ts`, `domain/declaration/binding-resolution.ts` | | Whether a branch is declared: only by `task`, on that branch (`aiddDeclaredAt` set, `aiddDeclaredFor` absent or equal to the branch); the hook's twin is `plugins/aidd-telemetry/hooks/lib/git.cjs` | `domain/branch-binding.ts` `parseBranchConfig`; rule and limits in the [usage contract](../../../aidd_docs/product/usage-contract.md#branch-declaration) | | What counts as consent | `domain/telemetry-consent.ts`, `infrastructure/git-consent-adapter.ts` | 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 c0d6c870f..c4c40cd99 100644 --- a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts @@ -70,7 +70,9 @@ export class IngestUsageUseCase { private readonly ledger: UsageLedger, private readonly directories: DirectoryResolver, private readonly snapshots: SnapshotBindingsUseCase, - private readonly options: IngestOptions + private readonly options: IngestOptions, + /** Brings stored data to today's key and id rules first (`StoredUsageMigration`). */ + private readonly migration: { execute(): Promise } ) {} async execute(): Promise { @@ -92,6 +94,8 @@ export class IngestUsageUseCase { } private async ingest(): Promise { + // Stored data first brought to the rules of now, before anything is added to it. + await this.migration.execute(); const before = await this.ledger.positions(); // The consent log first: a transcript last written before any interval opened is not read. const resolver = await this.directories.open(); diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts index 58f6af264..1adbc29a4 100644 --- a/cli/src/contexts/telemetry/application/ledger-upkeep.ts +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -1,3 +1,4 @@ +import { isKeyedBySession } from "../domain/formats/claude-transcript-usage.js"; import type { LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; import { daysAround, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; @@ -11,13 +12,16 @@ 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 * line that is not a record put after the records, kept. Nothing outside the window is read, - * except a monthly file it splits and every day of that month. Runs under the caller's lock. */ + * except a monthly file it splits and every day of that month, and every day when a call has no + * request id. Runs under the caller's lock. */ export async function storeCalls( ledger: UsageLedger, incoming: readonly StoredUsage[] ): Promise { - const days = daysAround(incoming); - const held = await ledger.load((day) => days.has(day)); + // A requestless call's copies may lie any distance apart: storing one reads every day, so + // the ledger never holds such a call twice and no window has to reach its other copies. + const days = incoming.some(isKeyedBySession) ? null : daysAround(incoming); + const held = await ledger.load(days === null ? undefined : (day) => days.has(day)); 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); diff --git a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts new file mode 100644 index 000000000..aa4869bc3 --- /dev/null +++ b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts @@ -0,0 +1,71 @@ +import { legacyRepositoryIdOf } from "../../domain/legacy/legacy-repository-identity.js"; +import type { RepositoryAliasStore } from "../../domain/ports/repository-alias-store.js"; +import type { RepositoryLocator } from "../../domain/ports/repository-locator.js"; +import type { ResolutionStore } from "../../domain/ports/resolution-store.js"; +import type { UsageLedger } from "../../domain/ports/usage-ledger.js"; +import { repositoryIdOf } from "../../domain/repository-identity.js"; + +/** Brings what an earlier version stored to the rules of now: one key per call, one id per + * repository. A stored id is a hash, so only a live clone's origin can say which id an earlier + * version gave it: each root `roots.json` names is located, and the earlier id of its origin + * mapped to the id of now. The table is written first; every reader of the ledger, + * `roots.json` and the branch snapshots applies it, and the key rule, from then on. Then + * `roots.json` and every ledger partition are rewritten under it, and the table is marked + * complete, last. A run stopped anywhere leaves a state the next one completes with the same + * table, asking git nothing. A root that could not be read, or was not there, stays pending: + * each later run looks at those roots alone, and rewrites the stores again only when one of + * them adds an id. Runs under the ledger's lock. */ +export class StoredUsageMigration { + constructor( + private readonly locator: RepositoryLocator, + private readonly resolutions: ResolutionStore, + private readonly aliases: RepositoryAliasStore, + private readonly ledger: UsageLedger + ) {} + + async execute(): Promise { + const held = await this.aliases.load(); + let table = held; + if (table === null || table.complete) { + const roots = table?.pending ?? (await this.roots()); + const found = await this.locate(roots); + const ids = new Map([...(table?.ids ?? []), ...found.ids]); + if (table !== null && ids.size === table.ids.size) { + // Nothing new: only the roots still pending may have changed. + if (found.pending.length < table.pending.length) { + await this.aliases.save({ ...table, pending: found.pending }); + } + return; + } + table = { ids, complete: false, pending: found.pending }; + await this.aliases.save(table); + } + const roots = await this.resolutions.load(); + if (roots.size > 0) await this.resolutions.save(roots); + // Every partition, not only the ones a read finds unsettled: a day is put in the order one + // ingest writes, whatever order it was found in. One already in it is not touched. + await this.ledger.save((await this.ledger.load()).records); + await this.aliases.save({ ...table, complete: true }); + } + + private async roots(): Promise { + return [...new Set([...(await this.resolutions.load()).values()].map((r) => r.root))]; + } + + private async locate( + roots: readonly string[] + ): Promise<{ ids: Map; pending: string[] }> { + const ids = new Map(); + const pending: string[] = []; + for (const root of roots) { + const located = await this.locator.locate(root); + if (located.status === "unreadable" || located.status === "gone") pending.push(root); + if (located.status !== "repository") continue; + // Both rules fall back to the root commit alike, so one is null only when both are. + const earlier = legacyRepositoryIdOf(located); + const now = repositoryIdOf(located); + if (earlier !== now) ids.set(earlier as string, now as string); + } + return { ids, pending }; + } +} diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts index 0f7015ef4..37fcbccf4 100644 --- a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -12,6 +12,9 @@ export interface ClaudeUsageLine { type Json = Record; const SYNTHETIC_MODEL = ""; +/** What an advisor iteration adds to its message's key. Anchored for clarity only: the format + * writes it as a suffix alone, and message and session ids hold no `#`. */ +export const ADVISOR = /#advisor\d+$/; const NONE: ClaudeUsageLine = { records: [], unrecognised: [] }; function counter(value: unknown): number | null { @@ -71,6 +74,15 @@ function sum(values: readonly (number | null)[]): number { return values.reduce((all, value) => all + (value ?? 0), 0); } +/** Whether a call is keyed `message.id:sessionId`, having no request id. Its copies are bound + * in time by nothing measured: none was seen in real transcripts, whereas the copies of a call + * with a request id were measured under half an hour apart (see `windowOf`). */ +export function isKeyedBySession(call: Pick): boolean { + return ( + call.tool === "claude-code" && call.key.replace(ADVISOR, "").endsWith(`:${call.session_id}`) + ); +} + export function readClaudeUsageLine(line: string): ClaudeUsageLine { let parsed: unknown; try { @@ -96,7 +108,7 @@ export function readClaudeUsageLine(line: string): ClaudeUsageLine { const notes: string[] = []; const requestId = text(root.requestId); - const key = requestId === null ? `${id}:${sessionId}:${at}` : `${id}:${requestId}`; + const key = `${id}:${requestId ?? sessionId}`; const iterations = messageIterations(usage, notes); const writes = cacheWrites(usage, iterations, notes); const shared = { diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts new file mode 100644 index 000000000..7960418d9 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts @@ -0,0 +1,11 @@ +import { addressOf, hashOf, ownerAndRepo, type RepositoryFacts } from "../repository-identity.js"; + +/** The id cli-v5.5.0 stored for a repository: the host case-folded only when scp-like or of a + * special scheme, the path as written. Kept so the migration can tell which stored id a live + * clone's origin had (see `StoredUsageMigration`); nothing new is ever named by it. */ +export function legacyRepositoryIdOf(facts: RepositoryFacts): string | null { + const address = facts.remote === null ? null : addressOf(facts.remote); + const path = address === null ? null : ownerAndRepo(address.path); + if (address === null || path === null) return facts.rootCommit; + return hashOf(`${address.scp ? address.host.toLowerCase() : address.host}/${path}`); +} diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts new file mode 100644 index 000000000..b68858b9d --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts @@ -0,0 +1,30 @@ +import { ADVISOR } from "../formats/claude-transcript-usage.js"; +import type { StoredUsage } from "../stored-usage.js"; +import type { UsageRecord } from "../usage-record.js"; + +/** A call's key as it is keyed now. An earlier version keyed a call with no request id + * `message.id:sessionId:timestamp`, so each copy of it had its own key; the timestamp is the + * call's own `at`, after its own session, and is dropped; only Claude Code was keyed so. Any + * other key is kept: one keyed now + * ends in its session, never in `:at`, so re-keying twice is re-keying once. */ +export function currentKeyOf( + call: Pick +): string { + if (call.tool !== "claude-code") return call.key; + const suffix = ADVISOR.exec(call.key)?.[0] ?? ""; + const base = call.key.slice(0, call.key.length - suffix.length); + const earlier = `:${call.session_id}:${call.at}`; + return base.endsWith(earlier) && base.length > earlier.length + ? `${base.slice(0, -call.at.length - 1)}${suffix}` + : call.key; +} + +/** A stored call as it is keyed and named now: the same object when nothing changed, so a + * reader can tell a partition that needs rewriting. */ +export function storedNow(record: T, ids: ReadonlyMap): T { + const key = currentKeyOf(record); + const repositoryId = ids.get(record.repository_id) ?? record.repository_id; + return key === record.key && repositoryId === record.repository_id + ? record + : { ...record, key, repository_id: repositoryId }; +} diff --git a/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts new file mode 100644 index 000000000..43649d26f --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts @@ -0,0 +1,37 @@ +import { tryParseJson } from "../../../../kernel/reading/json-file.js"; +import { asPlainObject } from "../../../../kernel/reading/plain-object.js"; +import { compareText } from "../text-order.js"; + +/** Which id an earlier version gave a repository that now has another, and whether every store + * has been rewritten to it. Both sides are hashes or root commits: no address is kept. */ +export interface RepositoryAliases { + readonly ids: ReadonlyMap; + /** Every store has been rewritten under `ids`. */ + readonly complete: boolean; + /** Roots that could not be read, or were not there, when last looked at: looked at again by + * the next run, since an earlier id can only be told from a clone's origin. */ + readonly pending: readonly string[]; +} + +const FORMAT = 1; + +/** `null` for a file missing, of another format, or damaged: the table is then made again. */ +export function parseRepositoryAliases(text: string | null): RepositoryAliases | null { + const parsed = tryParseJson(text ?? ""); + const object = parsed.ok ? asPlainObject(parsed.value) : null; + const aliases = asPlainObject(object?.aliases); + if (object?.version !== FORMAT || typeof object.complete !== "boolean" || aliases === null) { + return null; + } + const entries = Object.entries(aliases); + if (entries.some(([, id]) => typeof id !== "string" || id === "")) return null; + const { pending } = object; + if (!Array.isArray(pending) || pending.some((root) => typeof root !== "string")) return null; + return { ids: new Map(entries as [string, string][]), complete: object.complete, pending }; +} + +export function renderRepositoryAliases({ ids, complete, pending }: RepositoryAliases): string { + const aliases = Object.fromEntries([...ids].sort(([a], [b]) => compareText(a, b))); + const roots = [...pending].sort(compareText); + return `${JSON.stringify({ version: FORMAT, complete, aliases, pending: roots }, null, 2)}\n`; +} diff --git a/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts b/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts new file mode 100644 index 000000000..d138f76dc --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts @@ -0,0 +1,8 @@ +import type { RepositoryAliases } from "../legacy/repository-aliases.js"; + +/** The table of ids an earlier version gave repositories, beside the ledger. */ +export interface RepositoryAliasStore { + /** `null` when there is none yet. */ + load(): Promise; + save(aliases: RepositoryAliases): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/report/period.ts b/cli/src/contexts/telemetry/domain/report/period.ts index 367a87952..ad8e3a867 100644 --- a/cli/src/contexts/telemetry/domain/report/period.ts +++ b/cli/src/contexts/telemetry/domain/report/period.ts @@ -32,11 +32,14 @@ function shift(day: string, days: number): string | null { * from the day before `from` to the day after `to`, an open side, or one past four-digit * years, staying open. `undefined` when the period has no bound: every partition. * - * One day on each side, because the copies of one call have been observed less than a day - * apart (of 262,631 real keys, the widest spread is 0.3 h). Under that bound they sit within a + * One day on each side, because the copies of one call with a request id have been observed + * less than a day apart (of 262,631 real keys, the widest spread is 0.3 h; measured again on + * 184,502 message ids written more than once, 0.27 h). Under that bound they sit within a * calendar day of each other: a call with a copy in the period has all of them in the window, * and folds to the same one as a full read would. Outside it, only an unbounded read folds - * every copy. */ + * every copy. A call with no request id is bound by nothing measured (none was seen in + * 512,820 real usage lines), so the window does not rely on it: storing one reads every day + * (see `storeCalls`), and the ledger holds it once. */ export function windowOf(period: Period): ((day: string) => boolean) | undefined { if (period.from === null && period.to === null) return undefined; const first = period.from === null ? null : shift(period.from, -1); diff --git a/cli/src/contexts/telemetry/domain/repository-identity.ts b/cli/src/contexts/telemetry/domain/repository-identity.ts index cdd90cb72..0ca97ba94 100644 --- a/cli/src/contexts/telemetry/domain/repository-identity.ts +++ b/cli/src/contexts/telemetry/domain/repository-identity.ts @@ -4,15 +4,16 @@ const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i; /** `[user@]host:path`. A one-letter host is a Windows drive, not a host. */ const SCP_LIKE = /^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)/; -function ownerAndRepo(path: string): string | null { - const cleaned = path.replace(/^\/+/, "").replace(/(?:\.git)?\/*$/, ""); - return cleaned.includes("/") ? cleaned : null; +/** A remote's host and path as written, credentials and port dropped. `scp` says the host is + * the text before the colon, as typed; otherwise it is `URL`'s, which folds only the case of + * special schemes (`https`, not `ssh` or `git`). */ +export interface RemoteAddress { + readonly host: string; + readonly path: string; + readonly scp: boolean; } -/** `host/owner/repo` of a remote url, whatever its spelling: https, ssh, scp-like or git, with - * credentials, port, `.git` suffix and trailing slash dropped and the host case-folded. A local - * path, a `file://` url or anything with no owner and repo names no hosted repository. */ -export function normaliseRemote(url: string): string | null { +export function addressOf(url: string): RemoteAddress | null { const text = url.trim(); if (SCHEME.test(text)) { let parsed: URL; @@ -21,13 +22,43 @@ export function normaliseRemote(url: string): string | null { } catch { return null; } - const path = ownerAndRepo(parsed.pathname); - return parsed.hostname === "" || path === null ? null : `${parsed.hostname}/${path}`; + return parsed.hostname === "" + ? null + : { host: parsed.hostname, path: parsed.pathname, scp: false }; } const scp = SCP_LIKE.exec(text); - if (scp === null) return null; - const path = ownerAndRepo(scp[2] as string); - return path === null ? null : `${(scp[1] as string).toLowerCase()}/${path}`; + return scp === null ? null : { host: scp[1] as string, path: scp[2] as string, scp: true }; +} + +/** `owner/repo`, leading slashes, `.git` and trailing slashes dropped; `null` with no owner. */ +export function ownerAndRepo(path: string): string | null { + const cleaned = path.replace(/^\/+/, "").replace(/(?:\.git)?\/*$/, ""); + return cleaned.includes("/") ? cleaned : null; +} + +export function hashOf(address: string): string { + return createHash("sha256").update(address).digest("hex"); +} + +/** GitHub's SSH over the HTTPS port answers at `ssh.github.com` for the repositories of + * `github.com`. */ +const SAME_HOST: Readonly> = { "ssh.github.com": "github.com" }; +/** Hosts whose owner and repository names are not case sensitive, by their own documentation. + * Any other host keeps the case written: two spellings may give two ids, never one id two + * repositories. */ +const CASELESS_PATH = new Set(["github.com"]); + +/** `host/owner/repo` of a remote url, whatever its spelling: https, ssh, scp-like or git, with + * credentials, port, `.git` suffix and trailing slash dropped, the host case-folded, and the + * path too where the host ignores its case. A local path, a `file://` url or anything with no + * owner and repo names no hosted repository. */ +export function normaliseRemote(url: string): string | null { + const address = addressOf(url); + if (address === null) return null; + const folded = address.host.toLowerCase(); + const host = SAME_HOST[folded] ?? folded; + const path = ownerAndRepo(CASELESS_PATH.has(host) ? address.path.toLowerCase() : address.path); + return path === null ? null : `${host}/${path}`; } export interface RepositoryFacts { @@ -41,6 +72,5 @@ export interface RepositoryFacts { * its first commit. The url itself is never kept. */ export function repositoryIdOf(facts: RepositoryFacts): string | null { const hosted = facts.remote === null ? null : normaliseRemote(facts.remote); - if (hosted !== null) return createHash("sha256").update(hosted).digest("hex"); - return facts.rootCommit; + return hosted === null ? facts.rootCommit : hashOf(hosted); } diff --git a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts index 40de19c00..287f2cd71 100644 --- a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts @@ -3,6 +3,7 @@ import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; import { type BranchSnapshot, parseBranchSnapshot, snapshotKey } from "../domain/branch-binding.js"; import type { BindingSnapshotStore } from "../domain/ports/bindings/binding-snapshot-store.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; +import type { RepositoryAliasStore } from "../domain/ports/repository-alias-store.js"; /** `branches.jsonl`: append-only, one snapshot per line, the latest line of a branch being its * current one. It is appended to, never rewritten, because a snapshot is a fact about a moment @@ -12,16 +13,15 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { constructor( private readonly dir: string, - private readonly storage: PrivateStorage + private readonly storage: PrivateStorage, + private readonly aliases: RepositoryAliasStore ) { this.path = join(dir, "branches.jsonl"); } async history(): Promise> { const history = new Map(); - for (const line of ((await readTextIfPresent(this.path)) ?? "").split("\n")) { - const snapshot = parseBranchSnapshot(line); - if (snapshot === null) continue; + for (const snapshot of await this.read()) { const key = snapshotKey(snapshot.repository_id, snapshot.branch); history.set(key, [...(history.get(key) ?? []), snapshot]); } @@ -30,12 +30,25 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { async latest(): Promise> { const latest = new Map(); + for (const snapshot of await this.read()) { + latest.set(snapshotKey(snapshot.repository_id, snapshot.branch), snapshot); + } + return latest; + } + + /** Every snapshot, in the order taken, each repository by its id of now: a snapshot taken + * under an id an earlier version gave joins the history of the id it has now, and the file + * is never rewritten. */ + private async read(): Promise { + const ids = (await this.aliases.load())?.ids; + const snapshots: BranchSnapshot[] = []; for (const line of ((await readTextIfPresent(this.path)) ?? "").split("\n")) { const snapshot = parseBranchSnapshot(line); - if (snapshot !== null) - latest.set(snapshotKey(snapshot.repository_id, snapshot.branch), snapshot); + if (snapshot === null) continue; + const id = ids?.get(snapshot.repository_id); + snapshots.push(id === undefined ? snapshot : { ...snapshot, repository_id: id }); } - return latest; + return snapshots; } async append(snapshots: readonly BranchSnapshot[]): Promise { diff --git a/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts new file mode 100644 index 000000000..168948be4 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { readTextIfPresent } from "../../../../kernel/reading/text-file.js"; +import { + parseRepositoryAliases, + type RepositoryAliases, + renderRepositoryAliases, +} from "../../domain/legacy/repository-aliases.js"; +import type { PrivateStorage } from "../../domain/ports/private-storage.js"; +import type { RepositoryAliasStore } from "../../domain/ports/repository-alias-store.js"; + +/** `repository-ids.json`, beside the ledger. */ +export class RepositoryAliasStoreAdapter implements RepositoryAliasStore { + private readonly path: string; + + constructor( + private readonly ledgerDir: string, + private readonly storage: PrivateStorage + ) { + this.path = join(ledgerDir, "repository-ids.json"); + } + + async load(): Promise { + return parseRepositoryAliases(await readTextIfPresent(this.path)); + } + + async save(aliases: RepositoryAliases): Promise { + await this.storage.ensureDirectory(this.ledgerDir); + await this.storage.replace(this.path, renderRepositoryAliases(aliases)); + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts index 554041947..b91f81941 100644 --- a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts @@ -7,20 +7,31 @@ import { type RepositoryResolution, renderResolutions, } from "../domain/repository-resolution.js"; +import { RepositoryAliasStoreAdapter } from "./legacy/repository-alias-store-adapter.js"; /** `roots.json`, beside the ledger. */ export class ResolutionStoreAdapter implements ResolutionStore { private readonly path: string; + private readonly aliases: RepositoryAliasStoreAdapter; constructor( private readonly ledgerDir: string, private readonly storage: PrivateStorage ) { this.path = join(ledgerDir, "roots.json"); + // Its own directory's table, read at every load: no wiring can leave it out. + this.aliases = new RepositoryAliasStoreAdapter(ledgerDir, storage); } + /** Each repository by its id of now (see `RepositoryAliasStore`). */ async load(): Promise> { - return parseResolutions(await readTextIfPresent(this.path)); + const ids = (await this.aliases.load())?.ids; + const held = parseResolutions(await readTextIfPresent(this.path)); + for (const [key, resolution] of held) { + const id = ids?.get(resolution.repository_id); + if (id !== undefined) held.set(key, { ...resolution, repository_id: id }); + } + return held; } async save(resolutions: 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 47de1498e..9830de2b9 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -2,6 +2,7 @@ import { readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { isErrnoException } from "../../../kernel/reading/json-file.js"; import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; +import { storedNow } from "../domain/legacy/legacy-usage-key.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; import type { LeftOutStore, LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; import { @@ -19,6 +20,7 @@ import { import { foldUsage } from "../domain/usage-fold.js"; import { DirectoryLock, type LockOptions } from "./ledger-lock.js"; import { LeftOutAdapter } from "./left-out/left-out-adapter.js"; +import { RepositoryAliasStoreAdapter } from "./legacy/repository-alias-store-adapter.js"; const DAILY = /^\d{4}-\d{2}-\d{2}\.jsonl$/; const MONTHLY = /^\d{4}-\d{2}\.jsonl$/; @@ -37,7 +39,8 @@ function partitionOf(name: string): string { * ledger is never appended to: a changed day is rewritten whole, by temporary file and * 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 + * changed, so its cost does not grow with the ledger; one storing a call with no request id + * reads every day (see `storeCalls`). 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 with no bound * has settled the ledger, each call is on disk once and the bytes depend on what the ledger @@ -51,6 +54,7 @@ function partitionOf(name: string): string { * and could not store (see `LeftOutAdapter`); `.lock` serialises the writers of all of them. */ export class UsageLedgerAdapter implements UsageLedger { readonly leftOut: LeftOutStore; + private readonly aliases: RepositoryAliasStoreAdapter; constructor( private readonly dir: string, @@ -58,6 +62,8 @@ export class UsageLedgerAdapter implements UsageLedger { private readonly lockOptions: Partial = {} ) { this.leftOut = new LeftOutAdapter(join(dir, "left-out.txt"), dir, storage); + // Its own directory's table, read at every load: no wiring can leave it out. + this.aliases = new RepositoryAliasStoreAdapter(dir, storage); } exclusively(work: () => Promise): Promise { @@ -71,18 +77,23 @@ export class UsageLedgerAdapter implements UsageLedger { const damaged = new Set(); const unsettled = new Set(); const heldIn = new Map(); + // Every read applies the rules of now, so a call an earlier version keyed or named + // otherwise is still one call of one repository, and its partition is rewritten. + const ids = (await this.aliases.load())?.ids ?? new Map(); for (const name of holds === undefined ? names : inWindow(names, holds)) { 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) { + const read = parseStoredUsage(line); + if (read === null) { skippedLines += 1; damaged.add(partition); continue; } + const record = storedNow(read, ids); + if (record !== read) unsettled.add(partition); found.push(record); const id = `${record.tool}\u0000${record.key}`; const first = heldIn.get(id); diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 8d29ff6d6..275701108 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -9,6 +9,7 @@ import { ForgetTelemetryUseCase } from "../../contexts/telemetry/application/for import { ManageIdentityUseCase } from "../../contexts/telemetry/application/identity/manage-identity-use-case.js"; import { ProposeIdentityUseCase } from "../../contexts/telemetry/application/identity/propose-identity-use-case.js"; import { IngestUsageUseCase } from "../../contexts/telemetry/application/ingest-usage-use-case.js"; +import { StoredUsageMigration } from "../../contexts/telemetry/application/legacy/stored-usage-migration.js"; import { ReadUsageUseCase } from "../../contexts/telemetry/application/read-usage-use-case.js"; import { DeclaredBindings } from "../../contexts/telemetry/application/report/declared-bindings.js"; import { ReportUsageUseCase } from "../../contexts/telemetry/application/report/report-usage-use-case.js"; @@ -36,6 +37,7 @@ import { GitConsentAdapter } from "../../contexts/telemetry/infrastructure/git-c import { GitRepositoryLocatorAdapter } from "../../contexts/telemetry/infrastructure/git-repository-locator-adapter.js"; import { GitUserSourceAdapter } from "../../contexts/telemetry/infrastructure/identity/git-user-source-adapter.js"; import { PersonIdentityAdapter } from "../../contexts/telemetry/infrastructure/identity/person-identity-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; import { ResolutionStoreAdapter } from "../../contexts/telemetry/infrastructure/resolution-store-adapter.js"; import { ClaudeSettingsAdapter } from "../../contexts/telemetry/infrastructure/switch/claude-settings-adapter.js"; import { LegacyHookAdapter } from "../../contexts/telemetry/infrastructure/switch/legacy-hook-adapter.js"; @@ -88,7 +90,8 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { const gitEnv = environmentWithoutGitVariables(); const bindingsDir = join(root, "bindings"); const branchSource = new GitBranchBindingSourceAdapter(gitEnv); - const snapshotStore = new BindingSnapshotStoreAdapter(bindingsDir, storage); + const aliases = new RepositoryAliasStoreAdapter(ledgerDir, storage); + const snapshotStore = new BindingSnapshotStoreAdapter(bindingsDir, storage, aliases); const bindingsLock = new BindingsLockAdapter(bindingsDir, storage); const snapshotBindingsUseCase = new SnapshotBindingsUseCase( branchSource, @@ -143,7 +146,8 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { ledger, new DirectoryResolver(locator, consents, resolutionStore, consentHistory, environment), snapshotBindingsUseCase, - { refusedByEnvironment } + { refusedByEnvironment }, + new StoredUsageMigration(locator, resolutionStore, aliases, ledger) ); const claudeDir = claudeConfigDir(process.env.CLAUDE_CONFIG_DIR, homedir()); return { diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index e68ebec3c..783d3405e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -96,9 +96,10 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 9 }, // Ingest is four stages, each owned by a collaborator: read what the transcripts gained, // tie each call to a repository, store it, snapshot the branch declarations. The fifth is - // the environment's refusal. A stage folded into another would hide where the ledger is - // written. - { path: "src/contexts/telemetry/application/ingest-usage-use-case.ts", injected: 5 }, + // the environment's refusal. The sixth brings what an earlier version stored to today's key + // and id rules before anything is added to it, under the same lock. A stage folded into + // another would hide where the ledger is written. + { path: "src/contexts/telemetry/application/ingest-usage-use-case.ts", injected: 6 }, // Tying a call to a clone is four things it cannot fold into one: where the directory is, // what its clone says now, what was remembered of the directory, and when the clone // consented. The fifth is the machine's spelling of a directory and the clock. 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 22322e569..9c7a78dfb 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 @@ -9,6 +9,7 @@ import { readClaudeUsageLine } from "../../../../src/contexts/telemetry/domain/f import type { LocatedDirectory } from "../../../../src/contexts/telemetry/domain/ports/repository-locator.js"; import { resolutionKey } from "../../../../src/contexts/telemetry/domain/repository-resolution.js"; import { + ALREADY_MIGRATED, AUTUMN, cloneOf, FakeBindings, @@ -113,7 +114,8 @@ function setup(options: { refused?: boolean; caseInsensitive?: boolean } = {}) { now: () => NOW, }), snapshots, - { refusedByEnvironment: options.refused ?? false } + { refusedByEnvironment: options.refused ?? false }, + ALREADY_MIGRATED ); locator.directories.set("/work/a", repository("/work/a")); /** The clone at `path` consents: it says so now, and it opted in once. */ @@ -135,7 +137,8 @@ function setup(options: { refused?: boolean; caseInsensitive?: boolean } = {}) { now: () => NOW, }), snapshots, - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); return { ingestReadingAll, diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index a83c965f5..fb480ce33 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -216,3 +216,51 @@ describe("settling only a window", () => { expect((await bytes(dir))["2026-10-20.jsonl"]).toBe(torn); }); }); + +describe("storing a requestless call, whose copies nothing bounds in time", () => { + it("finds its copy days away, so the ledger holds the call once and a bounded read agrees", async () => { + // Found by the independent check: copies three days apart were stored in two days. + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2)]); + expect((await ledger.load()).records).toEqual([ + stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2), + ]); + expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); + }); + + it("reads every day for a batch mixing a requestless call with one that has a request id", async () => { + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [ + stored("msg_2:req_2", "2026-10-04T09:00:00.000Z", 5), + stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2), + ]); + expect((await ledger.load()).records.map((record) => record.key).sort()).toEqual([ + "msg_1:s-1", + "msg_2:req_2", + ]); + expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); + }); + + it("rewrites a day holding an older CLI's timestamped key when it settles that day", async () => { + const dir = join(root, "ledger"); + await mkdir(dir, { recursive: true }); + const older = stored("msg_1:s-1:2026-10-01T10:00:00.000Z", "2026-10-01T10:00:00.000Z", 1); + await writeFile(join(dir, "2026-10-01.jsonl"), `${JSON.stringify(older)}\n`); + await settleLedger(new UsageLedgerAdapter(dir, new PrivateStorageAdapter())); + expect(await readFile(join(dir, "2026-10-01.jsonl"), "utf8")).toBe( + `${JSON.stringify({ ...older, key: "msg_1:s-1" })}\n` + ); + }); + + it("still reads only the days around a call with a request id", async () => { + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-04T10:00:00.000Z", 2)]); + expect((await readdir(join(root, "ledger"))).sort()).toEqual([ + "2026-10-01.jsonl", + "2026-10-04.jsonl", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts new file mode 100644 index 000000000..942ef5585 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts @@ -0,0 +1,396 @@ +import { appendFile, 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 { DirectoryResolver } from "../../../../../src/contexts/telemetry/application/directory-resolver.js"; +import { IngestUsageUseCase } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { StoredUsageMigration } from "../../../../../src/contexts/telemetry/application/legacy/stored-usage-migration.js"; +import { ReadUsageUseCase } from "../../../../../src/contexts/telemetry/application/read-usage-use-case.js"; +import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; +import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; +import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; +import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import type { CloneIdentity } from "../../../../../src/contexts/telemetry/domain/consent/clone-identity.js"; +import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; +import { legacyRepositoryIdOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; +import type { PrivateStorage } from "../../../../../src/contexts/telemetry/domain/ports/private-storage.js"; +import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import { repositoryIdOf } from "../../../../../src/contexts/telemetry/domain/repository-identity.js"; +import { + renderResolutions, + resolutionKey, +} from "../../../../../src/contexts/telemetry/domain/repository-resolution.js"; +import { dayOf } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { foldUsage } from "../../../../../src/contexts/telemetry/domain/usage-fold.js"; +import { BindingSnapshotStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; +import { ResolutionStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/resolution-store-adapter.js"; +import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../../helpers/ports/failing-private-storage.js"; +import { + cloneOf, + FakeBindings, + FakeConsents, + FakeLocator, + grantedBy, + InMemoryBindingsLock, + InMemoryConsentHistory, + InMemoryIdentity, + InMemorySessions, + InMemorySnapshots, + InMemoryTranscripts, +} from "../../../../helpers/ports/in-memory-telemetry.js"; + +let root: string; +let runs = 0; +function named(prefix: string): string { + runs += 1; + return `${prefix}-${runs}`; +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-migration-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const NOW = new Date("2026-10-20T00:00:00.000Z"); + +/** Clones that consent, by their origin: three spellings of one GitHub repository, a host + * that keeps its path's case, and a repository with no origin. */ +const PLACES: Readonly> = { + "/work/a": "git@github.com:Acme/Tool.git", + "/work/b": "ssh://git@GitHub.com:22/acme/TOOL.git/", + "/work/c": "https://github.com/acme/tool", + "/work/d": "ssh://git@Git.Example.org/Team/Repo.git", + "/work/e": null, +}; +const DIRS = Object.keys(PLACES); +const facts = (dir: string) => ({ remote: PLACES[dir] ?? null, rootCommit: "c0ffee" }); +const cloneAt = (dir: string): CloneIdentity => cloneOf(`${dir}/.git`, { ino: dir }); + +/** The copies of one call carry these times: either side of a midnight, or two on one day. */ +const TIMES = [ + ["2026-10-07T23:59:59.000Z", "2026-10-08T00:00:01.000Z", "2026-10-08T00:00:03.000Z"], + ["2026-10-08T12:00:00.000Z", "2026-10-08T12:00:04.000Z"], + ["2026-10-31T23:59:58.000Z", "2026-11-01T00:00:02.000Z"], + // Days apart: a requestless call's copies are bound by nothing measured. + ["2026-10-03T10:00:00.000Z", "2026-10-06T09:00:00.000Z", "2026-10-12T11:00:00.000Z"], +]; + +interface Copy { + readonly call: number; + readonly time: number; + readonly output: number; + /** Read by an earlier version, before the migration, rather than after it. */ + readonly before: boolean; +} + +const copy = fc.record({ + call: fc.nat(23), + time: fc.nat(2), + output: fc.nat(3), + before: fc.boolean(), +}); + +/** A call is made in one place, by one session, with or without a request id, on or off the + * declared branch, and with or without an advisor: only its copies' times and counts differ. */ +function lineOf({ call, time, output }: Copy): string { + const dir = DIRS[call % DIRS.length] as string; + const times = TIMES[Math.floor(call / 2) % TIMES.length] as string[]; + const requestless = call % 2 === 0; + const advisor = call % 3 === 0; + return JSON.stringify({ + type: "assistant", + sessionId: `s-${call % 4}`, + ...(requestless ? {} : { requestId: `req_${call}` }), + timestamp: times[time % times.length], + cwd: dir, + gitBranch: call % 5 === 0 ? null : "feat/x", + message: { + id: `msg_${call}`, + model: "m", + usage: { + input_tokens: 1, + output_tokens: output, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...(advisor + ? { + iterations: [ + { type: "message", cache_creation_input_tokens: 0 }, + { type: "advisor_message", model: "a", input_tokens: 2, output_tokens: output }, + ], + } + : {}), + }, + }, + }); +} + +/** What cli-v5.5.0 keyed a call by: a requestless call by its own timestamp too. */ +function legacyKey(line: string, key: string): string { + const parsed = JSON.parse(line); + if (parsed.requestId !== undefined) return key; + const advisor = key.match(/#advisor\d+$/)?.[0] ?? ""; + return `${key.slice(0, key.length - advisor.length)}:${parsed.timestamp}${advisor}`; +} + +const scenario = fc.record({ + copies: fc.array(copy, { minLength: 1, maxLength: 24 }), + /** How the earlier version's reads were cut: each copy read before goes into one of them. */ + batches: fc.array(fc.nat(3), { minLength: 24, maxLength: 24 }), + /** Whether October is still in the earlier monthly layout. */ + monthly: fc.boolean(), + /** Clones git cannot read, or not there (an unmounted volume), at the first run after the + * upgrade, and back by the next. */ + away: fc.array( + fc.record({ + dir: fc.constantFrom(...DIRS), + as: fc.constantFrom("unreadable" as const, "gone" as const), + }), + { maxLength: 2 } + ), +}); +type Scenario = typeof scenario extends fc.Arbitrary ? T : never; + +/** Writes, as cli-v5.5.0 left them, the ledger, `roots.json` and branch snapshots of the copies + * it read: each read's calls folded by its own keys and ids, appended to their days, so a call + * can sit in several days and a day hold a call twice. */ +async function writeEarlierStore(dir: string, bindings: string, s: Scenario): Promise { + await mkdir(dir, { recursive: true }); + await mkdir(bindings, { recursive: true }); + const before = s.copies.map((c, n) => ({ c, n })).filter(({ c }) => c.before); + for (let batch = 0; batch < 4; batch += 1) { + const read = before + .filter(({ n }) => s.batches[n] === batch) + .flatMap(({ c }) => { + const line = lineOf(c); + const dirOf = JSON.parse(line).cwd as string; + return readClaudeUsageLine(line).records.map((record) => ({ + ...record, + key: legacyKey(line, record.key), + repository_id: legacyRepositoryIdOf(facts(dirOf)) as string, + })); + }); + for (const stored of foldUsage(read)) { + const day = dayOf(stored.at) as string; + const name = s.monthly && day.startsWith("2026-10") ? day.slice(0, 7) : day; + await appendFile(join(dir, `${name}.jsonl`), `${JSON.stringify(stored)}\n`); + } + } + await writeRoots(dir, legacyRepositoryIdOf); + await writeSnapshots(bindings, legacyRepositoryIdOf); +} + +/** Every clone, as its `on` recorded it, under the id the rule gives. */ +async function writeRoots(dir: string, idOf: typeof repositoryIdOf): Promise { + await mkdir(dir, { recursive: true }); + const seen = DIRS.map((d) => ({ + dir: d, + repository_id: idOf(facts(d)) as string, + root: d, + clone: cloneAt(d), + seen_at: NOW.toISOString(), + })); + await writeFile( + join(dir, "roots.json"), + renderResolutions(new Map(seen.map((r) => [resolutionKey(r.dir, r.clone), r]))) + ); +} + +/** One declaration of `feat/x` per clone, the task named after the clone, under the id the + * rule gives: the clones of one repository share one history. */ +async function writeSnapshots(bindings: string, idOf: typeof repositoryIdOf): Promise { + await mkdir(bindings, { recursive: true }); + const lines = DIRS.map((d, n) => + JSON.stringify({ + branch: "feat/x", + task: `task-${d.slice(-1)}`, + ticket: null, + declared_at: `2026-10-0${n + 1}T00:00:00.000Z`, + none: false, + repository_id: idOf(facts(d)), + branch_created_at: "2026-09-01T00:00:00.000Z", + snapshot_at: `2026-10-0${n + 1}T00:00:00.000Z`, + }) + ); + await writeFile(join(bindings, "branches.jsonl"), `${lines.join("\n")}\n`); +} + +function machine(name: string, storage: PrivateStorage = new PrivateStorageAdapter()) { + const dir = join(root, name, "ledger"); + const bindings = join(root, name, "bindings"); + const ledger = new UsageLedgerAdapter(dir, storage); + const aliases = new RepositoryAliasStoreAdapter(dir, storage); + const resolutions = new ResolutionStoreAdapter(dir, storage); + const transcripts = new InMemoryTranscripts(); + const locator = new FakeLocator(); + const consents = new FakeConsents(); + const history = new InMemoryConsentHistory(); + for (const d of DIRS) { + const clone = cloneAt(d); + locator.directories.set(d, { + status: "repository", + root: d, + mainRoot: d, + clone, + ...facts(d), + }); + consents.cloneSays(clone, grantedBy(clone)); + history.consented(clone); + } + const snapshots = new BindingSnapshotStoreAdapter(bindings, storage, aliases); + const ingest = new IngestUsageUseCase( + [new ReadUsageUseCase(transcripts, readClaudeUsageLine)], + ledger, + new DirectoryResolver(locator, consents, resolutions, history, { + caseInsensitiveFileSystem: false, + now: () => NOW, + }), + new SnapshotBindingsUseCase( + new FakeBindings(), + new InMemorySnapshots(), + new InMemoryBindingsLock(), + () => NOW + ), + { refusedByEnvironment: false }, + new StoredUsageMigration(locator, resolutions, aliases, ledger) + ); + const report = new ReportUsageUseCase( + ingest, + ledger, + new DeclaredBindings(new InMemorySessions(), snapshots), + new InMemoryIdentity(), + locator, + { refusedByEnvironment: false } + ); + return { dir, bindings, transcripts, report, locator }; +} + +const AXES: readonly ReportAxis[] = ["total", "repository", "task", "session", "agent", "day"]; + +/** Every axis, as the envelope prints it. */ +async function figures(m: ReturnType): Promise { + const out: string[] = []; + for (const axis of AXES) { + const result = await m.report.execute({ axis, period: { from: null, to: null } }); + if (result.status !== "reported") throw new Error(result.status); + out.push(JSON.stringify(reportEnvelopeOf(result))); + } + return out; +} + +/** Every file under the ledger, but the offsets, the lock and the migration's own table. */ +async function bytes(dir: string): Promise> { + const found: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (!entry.isFile() || ["offsets.json", ".lock", "repository-ids.json"].includes(entry.name)) + continue; + found[entry.name] = await readFile(join(dir, entry.name), "utf8"); + } + return Object.fromEntries(Object.entries(found).sort(([a], [b]) => (a < b ? -1 : 1))); +} + +/** The machine an earlier version left, now run by this one: the copies it read are stored as + * it stored them, the rest are still to be read. */ +async function upgraded(s: Scenario, storage?: PrivateStorage, name = named("up")) { + const m = machine(name, storage); + await writeEarlierStore(m.dir, m.bindings, s); + m.transcripts.files.set("/t/0.jsonl", s.copies.filter((c) => !c.before).map(lineOf)); + return { ...m, name }; +} + +/** One fresh ingest, under this version's rules, of every copy. */ +async function fresh(s: Scenario) { + const m = machine(named("fresh")); + await writeSnapshots(m.bindings, repositoryIdOf); + await writeRoots(m.dir, repositoryIdOf); + m.transcripts.files.set("/t/0.jsonl", s.copies.map(lineOf)); + return m; +} + +describe("stored data from an earlier version, once migrated", () => { + /** The migrated store reports, and lies on disk, as one fresh ingest does. */ + async function asFresh(s: Scenario): Promise { + const up = await upgraded(s); + const one = await fresh(s); + // The first run after the upgrade, with some clones out of reach; then they are back. + const back = new Map(s.away.map(({ dir }) => [dir, up.locator.directories.get(dir)])); + for (const { dir, as } of s.away) up.locator.directories.set(dir, { status: as }); + await up.report.execute({ axis: "total", period: { from: null, to: null } }); + for (const [dir, located] of back) if (located) up.locator.directories.set(dir, located); + await figures(up); + await figures(one); + expect(await figures(up)).toEqual(await figures(one)); + expect(await bytes(up.dir)).toEqual(await bytes(one.dir)); + } + + it("reports, and lies on disk, as one fresh ingest of the same copies under the rules of now", async () => { + await fc.assert(fc.asyncProperty(scenario, asFresh), { numRuns: 60 }); + }, 240_000); + + it("puts a day it neither re-keys nor re-ids in the order one ingest writes, whatever order it was found in", async () => { + // Found by the property: two calls of one day, in key order rather than time order, that + // no rule of now changes, and no later read touches. + await asFresh({ + copies: [ + { call: 7, time: 0, output: 0, before: true }, + { call: 9, time: 1, output: 0, before: true }, + { call: 0, time: 0, output: 0, before: false }, + ], + batches: Array.from({ length: 24 }, () => 0), + monthly: false, + away: [], + }); + }); + + it("migrated twice is migrated once", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (s) => { + const up = await upgraded(s); + await figures(up); + const once = await bytes(up.dir); + const table = await readFile(join(up.dir, "repository-ids.json"), "utf8"); + const again = machine(up.name); + await figures(again); + expect(await bytes(up.dir)).toEqual(once); + expect(await readFile(join(up.dir, "repository-ids.json"), "utf8")).toBe(table); + }), + { numRuns: 30 } + ); + }, 240_000); + + it("stopped at any write and run again, ends as a run never stopped", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (s) => { + const clean = await upgraded(s); + await figures(clean); + const expected = { bytes: await bytes(clean.dir), figures: await figures(clean) }; + for (let failAt = 1; ; failAt += 1) { + const name = named("crash"); + const crashed = await upgraded(s, new FailingStorage(failAt), name); + const stopped = await crashed.report + .execute({ axis: "total", period: { from: null, to: null } }) + .then( + () => false, + () => true + ); + const rerun = machine(name); + rerun.transcripts.files.set( + "/t/0.jsonl", + crashed.transcripts.files.get("/t/0.jsonl") ?? [] + ); + await figures(rerun); + expect(await figures(rerun)).toEqual(expected.figures); + expect(await bytes(rerun.dir)).toEqual(expected.bytes); + if (!stopped) break; + } + }), + { numRuns: 12 } + ); + }, 240_000); +}); diff --git a/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts index 3bf98d444..bf91be72b 100644 --- a/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts @@ -16,6 +16,7 @@ import type { Period } from "../../../../../src/contexts/telemetry/domain/report import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, cloneOf, FakeBindings, FakeConsents, @@ -194,7 +195,8 @@ function machine() { now: () => NOW, }), snapshots, - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); const report = new ReportUsageUseCase( ingest, diff --git a/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts index bf766ab9b..83f7a9204 100644 --- a/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts @@ -28,6 +28,7 @@ import { import type { StoredUsage } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage-record.js"; import { + ALREADY_MIGRATED, FakeBindings, FakeConsents, FakeLocator, @@ -132,7 +133,8 @@ async function reporterOf({ calls, declarations, person }: Ledger) { { caseInsensitiveFileSystem: false, now } ), new SnapshotBindingsUseCase(new FakeBindings(), snapshots, new InMemoryBindingsLock(), now), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ), ledger, new DeclaredBindings(sessions, snapshots), 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 1084fd49d..4d2734e93 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 @@ -22,6 +22,7 @@ import { import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; import { repositoryIdOf } from "../../../../../src/contexts/telemetry/domain/repository-identity.js"; import { + ALREADY_MIGRATED, AUTUMN, cloneOf, FakeBindings, @@ -86,7 +87,8 @@ function setup(options: { refused?: boolean } = {}) { new InMemoryBindingsLock(), () => new Date("2026-10-09T00:00:00.000Z") ), - { refusedByEnvironment: options.refused ?? false } + { refusedByEnvironment: options.refused ?? false }, + ALREADY_MIGRATED ); locator.directories.set("/work/a", { status: "repository", diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index 46013b1ee..e6b19cfef 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { appendFile, 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"; @@ -8,15 +8,18 @@ import { type IngestResult, IngestUsageUseCase, } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { storeCalls } from "../../../../../src/contexts/telemetry/application/ledger-upkeep.js"; import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import { currentKeyOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-usage-key.js"; import type { UsageLedger } from "../../../../../src/contexts/telemetry/domain/ports/usage-ledger.js"; import { attribute } from "../../../../../src/contexts/telemetry/domain/report/attribution.js"; import { inPeriod, type Period, + windowOf, } from "../../../../../src/contexts/telemetry/domain/report/period.js"; import { conflictsOf, @@ -33,6 +36,7 @@ import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, FakeBindings, FakeConsents, FakeLocator, @@ -219,7 +223,8 @@ function ingestOf(ledger: UsageLedger): IngestUsageUseCase { new InMemoryBindingsLock(), now ), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); } @@ -297,3 +302,118 @@ describe("a report on a bounded period", () => { ); }, 60_000); }); + +/** A call with no request id is keyed by its message and session, and nothing bounds how far + * apart its copies are written. A call with one keeps its copies under half a day apart, the + * measured bound. */ +const storedCall = fc.record({ + anchor: fc.nat(ANCHORS.length - 1), + requestless: fc.boolean(), + copies: fc.array( + fc.record({ + days: fc.integer({ min: -5, max: 5 }), + offset: fc.integer({ min: -HALF_DAY, max: HALF_DAY }), + output: fc.nat(5), + // Which ingest read this copy. + ingest: fc.nat(3), + // Read by an older CLI after the migration: a requestless copy then gets its own + // timestamped key, appended to its own day. + older: fc.boolean(), + }), + { minLength: 1, maxLength: 3 } + ), +}); + +describe("calls stored by ingests, a requestless call's copies days apart, some by an older CLI", () => { + it("leave every bounded read equal to the full ledger filtered to its period", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(storedCall, { minLength: 1, maxLength: 8 }), + period, + async (calls, asked) => { + const ledger = await ledgerWith(new Map()); + const dir = join(root, `ledger-${runs}`); + const olderOf = (requestless: boolean, older: boolean) => requestless && older; + for (let ingest = 0; ingest < 4; ingest += 1) { + const batch = calls.flatMap(({ anchor, requestless, copies }, index) => + copies + .filter((c) => c.ingest === ingest) + .map(({ days, offset, output, older }): StoredUsage => { + const at = new Date( + (ANCHORS[anchor] as number) + (requestless ? days * DAY_MS : 0) + offset + ).toISOString(); + const key = requestless ? `msg_${index}:s-1` : `msg_${index}:req_${index}`; + return { + tool: "claude-code", + tool_version: "1.0.0", + key: olderOf(requestless, older) ? `${key}:${at}` : key, + session_id: "s-1", + agent: "main", + at, + model: "m-1", + 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 older = batch.filter((record) => record.key.split(":").length > 2); + const now = batch.filter((record) => !older.includes(record)); + if (now.length > 0) await storeCalls(ledger, now); + for (const record of older) { + await appendFile( + join(dir, `${record.at.slice(0, 10)}.jsonl`), + `${JSON.stringify(record)}\n` + ); + } + } + const bounded = (await ledger.load(windowOf(asked))).records; + const full = (await ledger.load()).records; + // A stated limit, and the only one: a requestless call an older CLI also stored after + // the migration may sit in two days until a report with no period settles it, so a + // bounded read is exempt for that call alone. The full read is never exempt. + const exempt = new Set( + calls.flatMap(({ requestless, copies }, index) => + copies.some((c) => olderOf(requestless, c.older)) ? [`msg_${index}:s-1`] : [] + ) + ); + const inside = (records: readonly StoredUsage[]) => + records.filter((record) => inPeriod(record.at, asked) && !exempt.has(record.key)); + expect(inside(bounded)).toEqual(inside(full)); + // What the limit allows for those calls, and no less: the bounded read holds such a call + // once when a day of its window holds a copy of it, read off the disk here, and never + // drops it; the full read holds it once. + const window = windowOf(asked) ?? (() => true); + const onDisk = new Set(); + for (const name of await readdir(dir)) { + if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name) || !window(name.slice(0, 10))) continue; + for (const line of (await readFile(join(dir, name), "utf8")).split("\n")) { + if (line !== "") onDisk.add(currentKeyOf(JSON.parse(line))); + } + } + for (const key of exempt) { + const count = (records: readonly StoredUsage[]) => + records.filter((record) => record.key === key).length; + expect(count(bounded)).toBe(onDisk.has(key) ? 1 : 0); + expect(count(full)).toBe(1); + // Over-counting is all the limit allows: once the full read's copy is in the period, + // its day is in the window and the bounded read keeps that very copy. + const kept = full.find((record) => record.key === key); + if (kept !== undefined && inPeriod(kept.at, asked)) { + expect(bounded.find((record) => record.key === key)).toEqual(kept); + } + } + // One record per call, whatever its copies' spread. + expect(full.length).toBe(calls.filter((c) => c.copies.length > 0).length); + } + ), + { numRuns: 150 } + ); + }, 240_000); +}); diff --git a/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts b/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts index 9e92fa184..4275ffdb8 100644 --- a/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts @@ -14,6 +14,7 @@ import { ClaudeTranscriptSourceAdapter } from "../../../../src/contexts/telemetr import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, cloneOf, FakeBindings, FakeConsents, @@ -144,7 +145,8 @@ function machine(source: TranscriptSource) { new InMemoryBindingsLock(), () => NOW ), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); const locked = cloneKey(cloneOf("/work/locked/.git", { ino: "/work/locked" })); const lock = (refused: boolean) => diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts index dd88c81cc..6f85f4b7f 100644 --- a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; +import { + isKeyedBySession, + readClaudeUsageLine, +} from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; const USAGE = { input_tokens: 3, @@ -57,9 +60,20 @@ describe("one transcript line to usage records", () => { expect(only(line()).key).toBe("msg_A:req_A"); }); - it("keys a requestless call by message id, session id and timestamp", () => { - const requestless = line({ requestId: undefined }); - expect(only(requestless).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + it("keys a requestless call by message id and session id, so its copies are one key", () => { + const first = line({ requestId: undefined, timestamp: "2026-10-07T23:59:59.000Z" }); + const copy = line({ requestId: undefined, timestamp: "2026-10-08T00:00:02.000Z" }); + expect(only(first).key).toBe("msg_A:s-1"); + expect(only(copy).key).toBe("msg_A:s-1"); + }); + + it("keys a requestless call's advisor iterations under the same key", () => { + const usage = { + ...USAGE, + iterations: [{ type: "advisor_message", model: "m", input_tokens: 1, output_tokens: 2 }], + }; + const { records } = readClaudeUsageLine(line({ requestId: undefined }, { usage })); + expect(records.map((r) => r.key)).toEqual(["msg_A:s-1", "msg_A:s-1#advisor0"]); }); it("drops the synthetic model", () => { @@ -261,7 +275,7 @@ describe("one transcript line to usage records", () => { }); it("reads an empty request id as none", () => { - expect(only(line({ requestId: "" })).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + expect(only(line({ requestId: "" })).key).toBe("msg_A:s-1"); }); it("leaves a model that is not text unknown", () => { @@ -307,3 +321,19 @@ describe("one transcript line to usage records", () => { expect(outcome.unrecognised).toEqual(["usage.iterations holds a non-object"]); }); }); + +describe("a call keyed by its session, having no request id", () => { + it("is told by its key ending in its own session, advisor iteration or not", () => { + const call = (key: string, tool: "claude-code" | "codex" = "claude-code") => ({ + tool, + key, + session_id: "s-1", + }); + expect(isKeyedBySession(call("msg_A:s-1"))).toBe(true); + expect(isKeyedBySession(call("msg_A:s-1#advisor3"))).toBe(true); + expect(isKeyedBySession(call("msg_A:s-1#advisor10"))).toBe(true); + expect(isKeyedBySession(call("msg_A:req_A"))).toBe(false); + expect(isKeyedBySession(call("msg_A:xs-1"))).toBe(false); + expect(isKeyedBySession(call("msg_A:s-1", "codex"))).toBe(false); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts new file mode 100644 index 000000000..3f6b04205 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts @@ -0,0 +1,28 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { legacyRepositoryIdOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; + +const sha = (text: string): string => createHash("sha256").update(text).digest("hex"); + +/** What cli-v5.5.0 stored: the migration finds a stored id by computing it again. */ +describe("the repository id an earlier version stored", () => { + it.each([ + ["git@github.com:Acme/Widgets.git", "github.com/Acme/Widgets"], + ["git@GITHUB.com:acme/widgets", "github.com/acme/widgets"], + ["https://GitHub.COM:443/Acme/widgets.git/", "github.com/Acme/widgets"], + ["ssh://git@GitHub.COM:22/acme/widgets.git", "GitHub.COM/acme/widgets"], + ["git://GitHub.com/acme/widgets", "GitHub.com/acme/widgets"], + ["ssh://git@ssh.github.com:443/acme/widgets.git", "ssh.github.com/acme/widgets"], + ["https://u:p@git.example.org/Team/Repo.git", "git.example.org/Team/Repo"], + ])("of %s hashes %s", (remote, address) => { + expect(legacyRepositoryIdOf({ remote, rootCommit: "c0ffee" })).toBe(sha(address)); + }); + + it("is the root commit with no hosted origin, or none with neither", () => { + expect(legacyRepositoryIdOf({ remote: "/srv/x.git", rootCommit: "c0ffee" })).toBe("c0ffee"); + expect(legacyRepositoryIdOf({ remote: "https://github.com/x", rootCommit: "c0ffee" })).toBe( + "c0ffee" + ); + expect(legacyRepositoryIdOf({ remote: null, rootCommit: null })).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts new file mode 100644 index 000000000..0a292b40c --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { currentKeyOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-usage-key.js"; + +const AT = "2026-10-07T10:00:01.000Z"; +const call = (key: string, at = AT, session_id = "s-1", tool = "claude-code" as const) => ({ + tool, + key, + at, + session_id, +}); + +describe("a key an earlier version stored, as it is keyed now", () => { + it("drops the timestamp from a requestless key", () => { + expect(currentKeyOf(call(`msg_A:s-1:${AT}`))).toBe("msg_A:s-1"); + }); + + it("keeps an advisor iteration's suffix", () => { + expect(currentKeyOf(call(`msg_A:s-1:${AT}#advisor12`))).toBe("msg_A:s-1#advisor12"); + expect(currentKeyOf(call(`msg_A:s-1:${AT}#advisor3`))).toBe("msg_A:s-1#advisor3"); + }); + + it("keeps a key with a request id, and one keyed now, as they are", () => { + expect(currentKeyOf(call("msg_A:req_A"))).toBe("msg_A:req_A"); + expect(currentKeyOf(call("msg_A:req_A#advisor0"))).toBe("msg_A:req_A#advisor0"); + expect(currentKeyOf(call("msg_A:s-1"))).toBe("msg_A:s-1"); + }); + + it("drops only the call's own time, after its own session", () => { + expect(currentKeyOf(call(`msg_A:s-2:${AT}`))).toBe(`msg_A:s-2:${AT}`); + expect(currentKeyOf(call(`msg_A:s-1:${AT}`, "2026-10-07T10:00:02.000Z"))).toBe( + `msg_A:s-1:${AT}` + ); + expect(currentKeyOf(call(`msg_A:xs-1:${AT}`))).toBe(`msg_A:xs-1:${AT}`); + expect(currentKeyOf(call(`s-1:${AT}`))).toBe(`s-1:${AT}`); + expect(currentKeyOf(call(`:s-1:${AT}`))).toBe(`:s-1:${AT}`); + }); + + it("re-keys only what Claude Code keyed so, never another tool's key", () => { + expect(currentKeyOf({ tool: "codex", key: `msg_A:s-1:${AT}`, at: AT, session_id: "s-1" })).toBe( + `msg_A:s-1:${AT}` + ); + }); + + it("re-keys at most once", () => { + const once = currentKeyOf(call(`msg_A:s-1:${AT}#advisor0`)); + expect(currentKeyOf(call(once))).toBe(once); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts new file mode 100644 index 000000000..ce261e787 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + parseRepositoryAliases, + renderRepositoryAliases, +} from "../../../../../src/contexts/telemetry/domain/legacy/repository-aliases.js"; + +describe("the table of ids an earlier version gave repositories", () => { + it("reads back what it wrote, its ids in text order", () => { + const ids = new Map([ + ["b", "y"], + ["a", "x"], + ]); + const text = renderRepositoryAliases({ ids, complete: false, pending: ["/w/b", "/w/a"] }); + expect(text).toBe( + `${JSON.stringify({ version: 1, complete: false, aliases: { a: "x", b: "y" }, pending: ["/w/a", "/w/b"] }, null, 2)}\n` + ); + expect(parseRepositoryAliases(text)).toEqual({ + ids, + complete: false, + pending: ["/w/a", "/w/b"], + }); + }); + + it.each([ + ["missing", null], + ["not JSON", "{"], + [ + "another format, all else valid", + JSON.stringify({ version: 2, complete: true, aliases: {}, pending: [] }), + ], + [ + "a completion that is no boolean, all else valid", + JSON.stringify({ version: 1, complete: "yes", aliases: {}, pending: [] }), + ], + [ + "no table, all else valid", + JSON.stringify({ version: 1, complete: true, aliases: null, pending: [] }), + ], + ["no completion", JSON.stringify({ version: 1, aliases: {} })], + ["a completion that is no boolean", JSON.stringify({ version: 1, complete: 1, aliases: {} })], + ["no table", JSON.stringify({ version: 1, complete: true })], + [ + "an id that is no text", + JSON.stringify({ version: 1, complete: true, aliases: { a: 1 }, pending: [] }), + ], + ["no pending roots", JSON.stringify({ version: 1, complete: true, aliases: {} })], + [ + "a pending root that is no text", + JSON.stringify({ version: 1, complete: true, aliases: {}, pending: [1] }), + ], + [ + "an empty id", + JSON.stringify({ version: 1, complete: true, aliases: { a: "x", b: "" }, pending: [] }), + ], + ])("is none when %s, so it is made again", (_name, text) => { + expect(parseRepositoryAliases(text)).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts index fba32f63d..2eaacb767 100644 --- a/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts @@ -27,6 +27,17 @@ describe("a remote is reduced to host/owner/repo", () => { ["several slashes before the path", "https://github.com//acme/widgets"], ["several slashes after the path", "https://github.com/acme/widgets//"], ["several slashes after .git", "https://github.com/acme/widgets.git//"], + ["owner and repo in another case", "git@github.com:Acme/Widgets.git"], + ["https in another case", "https://github.com/ACME/widgets"], + ["upper-case .GIT on github.com", "https://github.com/acme/widgets.GIT"], + ["ssh:// upper-case host", "ssh://git@GitHub.COM/acme/widgets.git"], + ["ssh:// upper-case host with a port", "ssh://git@GitHub.COM:22/Acme/Widgets.git/"], + ["git:// upper-case host", "git://GITHUB.com/acme/widgets"], + ["git+ssh:// upper-case host", "git+ssh://git@GitHub.com/acme/widgets.git"], + ["ssh over the https port", "ssh://git@ssh.github.com:443/acme/widgets.git"], + ["ssh over the https port, upper case", "ssh://git@SSH.GitHub.com:443/Acme/Widgets"], + ["https with a port", "https://github.com:443/acme/widgets"], + ["credentials and a port", "https://u:p@GitHub.com:8443/Acme/widgets.git/"], ])("%s gives one spelling", (_name, url) => { expect(normaliseRemote(url)).toBe(CANONICAL); }); @@ -52,8 +63,22 @@ describe("a remote is reduced to host/owner/repo", () => { ); }); - it("keeps the case of the path: only the host is case-folded", () => { - expect(normaliseRemote("https://github.com/Acme/Widgets")).toBe("github.com/Acme/Widgets"); + it("keeps the case of the path on a host not known to ignore it, whatever the form", () => { + for (const url of [ + "https://git.example.org/Team/Repo.git", + "ssh://git@Git.Example.ORG:2222/Team/Repo.git/", + "git@GIT.example.org:Team/Repo", + "git://git.example.org/Team/Repo", + ]) { + expect(normaliseRemote(url)).toBe("git.example.org/Team/Repo"); + } + }); + + it("does not take a host that merely contains github.com for github.com", () => { + expect(normaliseRemote("https://github.com.example.org/Acme/X")).toBe( + "github.com.example.org/Acme/X" + ); + expect(normaliseRemote("git@ghe.acme.io:Acme/X.git")).toBe("ghe.acme.io/Acme/X"); }); it.each([ @@ -81,8 +106,10 @@ describe("a repository id", () => { it("is the sha256 of the normalised remote, whatever its spelling", () => { const a = repositoryIdOf({ remote: "git@github.com:acme/widgets.git", rootCommit: "c0ffee" }); const b = repositoryIdOf({ remote: "https://u:p@GitHub.com/acme/widgets", rootCommit: "beef" }); + const c = repositoryIdOf({ remote: "ssh://git@GitHub.com/Acme/Widgets.git", rootCommit: null }); expect(a).toBe(sha(CANONICAL)); expect(b).toBe(a); + expect(c).toBe(a); }); it("never contains the remote it came from", () => { diff --git a/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts index 5c7a46731..c406cfbf8 100644 --- a/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts @@ -8,6 +8,7 @@ import { } from "../../../../src/contexts/telemetry/domain/branch-binding.js"; import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { NO_ALIASES } from "../../../helpers/ports/in-memory-telemetry.js"; let root: string; let dir: string; @@ -16,7 +17,7 @@ let store: BindingSnapshotStoreAdapter; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "aidd-snapshots-")); dir = join(root, "bindings"); - store = new BindingSnapshotStoreAdapter(dir, new PrivateStorageAdapter()); + store = new BindingSnapshotStoreAdapter(dir, new PrivateStorageAdapter(), NO_ALIASES); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); diff --git a/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts index b1438ac77..9e30be44f 100644 --- a/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts @@ -7,7 +7,7 @@ import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/ import { BindingsLockAdapter } from "../../../../src/contexts/telemetry/infrastructure/bindings-lock-adapter.js"; import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; -import { FakeBindings } from "../../../helpers/ports/in-memory-telemetry.js"; +import { FakeBindings, NO_ALIASES } from "../../../helpers/ports/in-memory-telemetry.js"; let root: string; const storage = new PrivateStorageAdapter(); @@ -98,7 +98,7 @@ describe("the bindings lock", () => { const snapshotter = () => new SnapshotBindingsUseCase( source, - new BindingSnapshotStoreAdapter(dir, storage), + new BindingSnapshotStoreAdapter(dir, storage, NO_ALIASES), new BindingsLockAdapter(dir, storage), () => new Date("2026-10-09T09:00:00.000Z") ); diff --git a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts new file mode 100644 index 000000000..3d245be94 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts @@ -0,0 +1,348 @@ +import { mkdir, mkdtemp, 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 { StoredUsageMigration } from "../../../../src/contexts/telemetry/application/legacy/stored-usage-migration.js"; +import { legacyRepositoryIdOf } from "../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; +import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; +import { repositoryIdOf } from "../../../../src/contexts/telemetry/domain/repository-identity.js"; +import { renderResolutions } from "../../../../src/contexts/telemetry/domain/repository-resolution.js"; +import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; +import { ResolutionStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/resolution-store-adapter.js"; +import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../helpers/ports/failing-private-storage.js"; +import { cloneOf, FakeLocator } from "../../../helpers/ports/in-memory-telemetry.js"; + +let dir: string; +let bindings: string; +const storage = new PrivateStorageAdapter(); + +beforeEach(async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-ids-")); + dir = join(root, "ledger"); + bindings = join(root, "bindings"); + await mkdir(dir, { recursive: true }); + await mkdir(bindings, { recursive: true }); +}); +afterEach(async () => { + await rm(join(dir, ".."), { recursive: true, force: true }); +}); + +const MIXED = { remote: "git@github.com:Acme/Tool.git", rootCommit: "c0ffee" }; +const OLD = legacyRepositoryIdOf(MIXED) as string; +const NEW = repositoryIdOf(MIXED) as string; + +function call(overrides: Record = {}) { + return { + tool: "claude-code", + tool_version: "2", + key: "msg_A:s-1:2026-10-07T23:59:59.000Z", + session_id: "s-1", + agent: "main", + at: "2026-10-07T23:59:59.000Z", + model: "m", + input: 1, + output: 1, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: "/work/a", + git_branch: null, + repository_id: OLD, + ...overrides, + }; +} + +async function writeAliases(complete = true): Promise { + await writeFile( + join(dir, "repository-ids.json"), + `${JSON.stringify({ version: 1, complete, aliases: { [OLD]: NEW }, pending: [] }, null, 2)}\n` + ); +} + +describe("the ledger, read after the rules changed", () => { + it("re-keys a requestless call's copies stored apart, re-ids them, and counts the call once", async () => { + await writeAliases(); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const later = call({ + key: "msg_A:s-1:2026-10-08T00:00:02.000Z", + at: "2026-10-08T00:00:02.000Z", + output: 9, + }); + await writeFile(join(dir, "2026-10-08.jsonl"), `${JSON.stringify(later)}\n`); + const ledger = new UsageLedgerAdapter(dir, storage); + const held = await ledger.load(); + expect(held.records).toEqual([{ ...later, key: "msg_A:s-1", repository_id: NEW }]); + expect([...held.unsettled].sort()).toEqual(["2026-10-07", "2026-10-08"]); + await ledger.save(held.records, held.unsettled); + expect(await readFile(join(dir, "2026-10-08.jsonl"), "utf8")).toBe( + `${JSON.stringify({ ...later, key: "msg_A:s-1", repository_id: NEW })}\n` + ); + await expect(readFile(join(dir, "2026-10-07.jsonl"), "utf8")).rejects.toThrow(); + }); + + it("folds a line an earlier version writes after the migration into the call it is a copy of", async () => { + await writeAliases(); + const now = call({ key: "msg_A:s-1", repository_id: NEW, output: 5 }); + await writeFile( + join(dir, "2026-10-07.jsonl"), + `${JSON.stringify(now)}\n${JSON.stringify(call({ output: 2 }))}\n` + ); + const held = await new UsageLedgerAdapter(dir, storage).load(); + expect(held.records).toEqual([now]); + }); + + it("leaves a call keyed and named by the rules of now as settled", async () => { + await writeAliases(); + const now = call({ key: "msg_A:req_A", repository_id: NEW }); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(now)}\n`); + const held = await new UsageLedgerAdapter(dir, storage).load(); + expect(held.records).toEqual([now]); + expect(held.unsettled.size).toBe(0); + }); +}); + +describe("roots.json and branch snapshots, read after the rules changed", () => { + const clone = cloneOf("/work/a/.git"); + const seen = { dir: "/work/a", root: "/work/a", clone, seen_at: "2026-10-01T00:00:00.000Z" }; + + it("name each repository by its id of now", async () => { + await writeAliases(); + await writeFile( + join(dir, "roots.json"), + renderResolutions(new Map([["k", { ...seen, repository_id: OLD }]])) + ); + const loaded = await new ResolutionStoreAdapter(dir, storage).load(); + expect([...loaded.values()].map((r) => r.repository_id)).toEqual([NEW]); + }); + + it("join an earlier id's snapshots to the history of its id of now, the file untouched", async () => { + await writeAliases(); + const snapshot = (id: string, task: string) => + JSON.stringify({ + branch: "feat/x", + task, + ticket: null, + declared_at: "2026-10-01T00:00:00.000Z", + none: false, + repository_id: id, + branch_created_at: null, + snapshot_at: "2026-10-01T00:00:00.000Z", + }); + const text = `${snapshot(OLD, "one")}\n${snapshot(NEW, "two")}\n`; + await writeFile(join(bindings, "branches.jsonl"), text); + const store = new BindingSnapshotStoreAdapter( + bindings, + storage, + new RepositoryAliasStoreAdapter(dir, storage) + ); + const history = await store.history(); + expect([...history.keys()]).toEqual([`${NEW}\u0000feat/x`]); + expect(history.get(`${NEW}\u0000feat/x`)?.map((s) => s.task)).toEqual(["one", "two"]); + expect((await store.latest()).get(`${NEW}\u0000feat/x`)?.task).toBe("two"); + expect(await readFile(join(bindings, "branches.jsonl"), "utf8")).toBe(text); + }); +}); + +describe("the migration", () => { + function migration(locator: FakeLocator, store: PrivateStorage = storage) { + const ledger = new UsageLedgerAdapter(dir, store); + return new StoredUsageMigration( + locator, + new ResolutionStoreAdapter(dir, store), + new RepositoryAliasStoreAdapter(dir, store), + ledger + ); + } + const live = (remote: string | null) => ({ + status: "repository" as const, + root: "/work/a", + mainRoot: "/work/a", + clone: cloneOf("/work/a/.git"), + remote, + rootCommit: "c0ffee", + }); + const rooted = async (...roots: string[]) => + writeFile( + join(dir, "roots.json"), + renderResolutions( + new Map( + roots.map((r) => [ + r, + { + dir: r, + root: r, + repository_id: OLD, + clone: cloneOf(`${r}/.git`), + seen_at: "2026-10-01T00:00:00.000Z", + }, + ]) + ) + ) + ); + const table = async () => JSON.parse(await readFile(join(dir, "repository-ids.json"), "utf8")); + + it("maps the id an earlier version gave each live root's origin to the id of now, and rewrites roots.json and the ledger", async () => { + await rooted("/work/a", "/work/gone"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live(MIXED.remote)); + await migration(locator).execute(); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: { [OLD]: NEW }, + pending: ["/work/gone"], + }); + expect(locator.asked).toEqual(["/work/a", "/work/gone"]); + expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( + `${JSON.stringify(call({ key: "msg_A:s-1", repository_id: NEW }))}\n` + ); + }); + + it("maps nothing for an origin both rules name alike, nor for a root with no origin", async () => { + await rooted("/work/a", "/work/b", "/work/c"); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live("https://github.com/acme/tool")); + locator.directories.set("/work/b", live(null)); + locator.directories.set("/work/c", { ...live(null), rootCommit: null }); + await migration(locator).execute(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); + }); + + it("leaves no roots.json where there was none", async () => { + await migration(new FakeLocator()).execute(); + await expect(readFile(join(dir, "roots.json"), "utf8")).rejects.toThrow(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); + }); + + it("maps a root it could not read at its first run once it can, so the repository keeps one id", async () => { + // Found by the independent check: the table was marked complete with the root unmapped. + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + await migration(locator).execute(); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: {}, + pending: ["/work/a"], + }); + locator.directories.set("/work/a", live(MIXED.remote)); + await migration(locator).execute(); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: { [OLD]: NEW }, + pending: [], + }); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( + `${JSON.stringify(call({ key: "msg_A:s-1", repository_id: NEW }))}\n` + ); + expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); + }); + + it("stopped at any write of a pending root's retry and run again, ends as a retry never stopped", async () => { + const files = async () => + Promise.all( + ["2026-10-07.jsonl", "roots.json", "repository-ids.json"].map((name) => + readFile(join(dir, name), "utf8") + ) + ); + const firstRun = async () => { + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + await rm(join(dir, "repository-ids.json"), { force: true }); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + await migration(locator).execute(); + locator.directories.set("/work/a", live(MIXED.remote)); + return locator; + }; + await migration(await firstRun()).execute(); + const clean = await files(); + for (let failAt = 1; ; failAt += 1) { + const locator = await firstRun(); + const stopped = await migration(locator, new FailingStorage(failAt)) + .execute() + .then( + () => false, + () => true + ); + await migration(locator).execute(); + expect(await files()).toEqual(clean); + if (!stopped) break; + } + }); + + it("keeps a root gone at its first run pending, and drops one that is no repository now", async () => { + await rooted("/work/a", "/work/b"); + const locator = new FakeLocator(); + locator.directories.set("/work/b", { status: "outside-repository" }); + await migration(locator).execute(); + expect((await table()).pending).toEqual(["/work/a"]); + locator.directories.set("/work/a", { status: "outside-repository" }); + await migration(locator).execute(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); + }); + + it("asks only the pending roots again, and writes nothing while none can be read", async () => { + await rooted("/work/a", "/work/b"); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + locator.directories.set("/work/b", live("https://github.com/acme/tool")); + await migration(locator).execute(); + const before = await table(); + locator.asked.length = 0; + await migration(locator, new FailingStorage(1)).execute(); + expect(locator.asked).toEqual(["/work/a"]); + expect(await table()).toEqual(before); + }); + + it("asks git nothing and rewrites nothing once complete", async () => { + await rooted("/work/a"); + await writeAliases(true); + const earlier = `${JSON.stringify(call())}\n`; + await writeFile(join(dir, "2026-10-07.jsonl"), earlier); + const roots = await readFile(join(dir, "roots.json"), "utf8"); + const locator = new FakeLocator(); + await migration(locator).execute(); + expect(locator.asked).toEqual([]); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe(earlier); + expect(await readFile(join(dir, "roots.json"), "utf8")).toBe(roots); + }); + + it("finishes an unfinished one with its own table, asking git nothing", async () => { + await rooted("/work/a"); + await writeAliases(false); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + await migration(locator).execute(); + expect(locator.asked).toEqual([]); + expect(await table()).toMatchObject({ complete: true, aliases: { [OLD]: NEW } }); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toContain(NEW); + }); + + it("writes its table before anything else, and says it is complete last", async () => { + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live(MIXED.remote)); + await expect(migration(locator, new FailingStorage(2)).execute()).rejects.toThrow(); + expect(await table()).toEqual({ + version: 1, + complete: false, + aliases: { [OLD]: NEW }, + pending: [], + }); + // Rerun: roots.json, the day, then the table marked complete, which fails. + await expect(migration(new FakeLocator(), new FailingStorage(3)).execute()).rejects.toThrow(); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toContain(NEW); + expect((await table()).complete).toBe(false); + }); +}); diff --git a/cli/tests/e2e/telemetry-ingest.e2e.test.ts b/cli/tests/e2e/telemetry-ingest.e2e.test.ts index c5e06c4ba..b91e7caff 100644 --- a/cli/tests/e2e/telemetry-ingest.e2e.test.ts +++ b/cli/tests/e2e/telemetry-ingest.e2e.test.ts @@ -134,6 +134,7 @@ describe("aidd telemetry ingest", () => { "2026-10-07.jsonl", "consents.jsonl", "offsets.json", + "repository-ids.json", "roots.json", ]); }); diff --git a/cli/tests/helpers/ports/in-memory-telemetry.ts b/cli/tests/helpers/ports/in-memory-telemetry.ts index c807a6f26..7b9168df1 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry.ts @@ -30,6 +30,7 @@ import type { import type { ConsentHistory } from "../../../src/contexts/telemetry/domain/ports/consent-history.js"; import type { ConsentSource } from "../../../src/contexts/telemetry/domain/ports/consent-source.js"; import type { PersonIdentityStore } from "../../../src/contexts/telemetry/domain/ports/identity/person-identity-store.js"; +import type { RepositoryAliasStore } from "../../../src/contexts/telemetry/domain/ports/repository-alias-store.js"; import type { LocatedDirectory, RepositoryLocator, @@ -411,3 +412,12 @@ export class InMemoryIdentity implements PersonIdentityStore { return held; } } + +/** A ledger already under today's key and id rules: the migration finds nothing to do. */ +export const ALREADY_MIGRATED = { execute: async (): Promise => {} }; + +/** No table of ids an earlier version gave repositories: every id is read as stored. */ +export const NO_ALIASES: RepositoryAliasStore = { + load: async () => null, + save: async () => {}, +};