Public: anyone can read this; the crew can change it.

Cost ledger: every finished run appends one hash-chained cost entry, and a script verifies the chain #12225

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Every finished run now appends one hash-chained entry to `$FORGE_DATA/ledger/<yyyy-mm>.jsonl`, and `bun scripts/ledger-verify.ts [dir]` checks the chain. The work was already committed on this branch and already pushed. I changed nothing this run, so `git push` had nothing new to send. `scripts/gate.ts` and `tsc --noEmit` came back clean, and `test/cost-ledger.test.ts` passes (4 tests). `scripts/f…

shipped production is serving it deploy 3ae56cb

opened this

Every finished run now appends one hash-chained entry to $FORGE_DATA/ledger/<yyyy-mm>.jsonl, and bun scripts/ledger-verify.ts [dir] checks the chain. The work was already committed on this branch and already pushed. I changed nothing this run, so git push had nothing new to send. scripts/gate.ts and tsc --noEmit came back clean, and test/cost-ledger.test.ts passes (4 tests). scripts/fast.ts said the diff is too broad to name test files, so I ran only the ledger tests, not the other suites the diff might touch. The forge's check runs the full suite on the push.

What changed

  • src/core/ledger.ts is new. It builds the entry, hashes it, and appends it under an in-process lock so two runs ending together never share a prev. The first entry's prev is 64 zeros.
  • Each entry holds run, kind, repo, issue, pull, startedAt, endedAt, wall_s, tokens_in, tokens_out, usd_tokens, prev, hash. hash is sha256 over the sorted-key entry without hash. Fields the run doesn't know yet are absent, not zero.
  • The append can't throw into the finish path; a failure is logged and the run finishes anyway.
  • src/core/runs.ts calls the append once when a run ends, and src/main.ts has a 3-line change.
  • scripts/ledger-verify.ts prints ok N entries, or the first broken line and why, and exits 1 on a break.
  • docs/features/cost-ledger.md says what an entry holds and how to verify.

What the tests cover

  • A run that ends writes one entry.
  • Two ends in a row chain.
  • Editing one field breaks verification at that line.
  • A day with no runs writes nothing.

Where this differs from the issue

  • The tests are in test/cost-ledger.test.ts, not test/ledger.test.ts. The decision ledger already has a test at the issue's path, and I kept it.
  • I didn't touch src/flow/, src/web/ or docs/design/, and I added no dependencies.

To try it: let a run finish, then run bun scripts/ledger-verify.ts $FORGE_DATA/ledger.

🤖 Generated with Claude Code

Closes #12112

2 earlier comments · 2 events
commented

Every finished run now appends one hash-chained entry to $FORGE_DATA/ledger/<yyyy-mm>.jsonl, and bun scripts/ledger-verify.ts [dir] checks the chain. The work was already committed on this branch and already pushed. I changed nothing this run, so git push had nothing new to send. scripts/gate.ts and tsc --noEmit came back clean, and test/cost-ledger.test.ts passes (4 tests). scripts/fast.ts said the diff is too broad to name test files, so I ran only the ledger tests, not the other suites the diff might touch. The forge's check runs the full suite on the push.

What changed

  • src/core/ledger.ts is new. It builds the entry, hashes it, and appends it under an in-process lock so two runs ending together never share a prev. The first entry's prev is 64 zeros.
  • Each entry holds run, kind, repo, issue, pull, startedAt, endedAt, wall_s, tokens_in, tokens_out, usd_tokens, prev, hash. hash is sha256 over the sorted-key entry without hash. Fields the run doesn't know yet are absent, not zero.
  • The append can't throw into the finish path; a failure is logged and the run finishes anyway.
  • src/core/runs.ts calls the append once when a run ends, and src/main.ts has a 3-line change.
  • scripts/ledger-verify.ts prints ok N entries, or the first broken line and why, and exits 1 on a break.
  • docs/features/cost-ledger.md says what an entry holds and how to verify.

What the tests cover

  • A run that ends writes one entry.
  • Two ends in a row chain.
  • Editing one field breaks verification at that line.
  • A day with no runs writes nothing.

Where this differs from the issue

  • The tests are in test/cost-ledger.test.ts, not test/ledger.test.ts. The decision ledger already has a test at the issue's path, and I kept it.
  • I didn't touch src/flow/, src/web/ or docs/design/, and I added no dependencies.

To try it: let a run finish, then run bun scripts/ledger-verify.ts $FORGE_DATA/ledger.

🤖 Generated with Claude Code

Changes requested — before the latest push

The chain, the hashing and the verifier work, but I am requesting changes on these, all in one round:

  1. Box 1 is not met: snapshots (and cancelled runs) never reach finishRun, so they write no entry. The docs admit it ("not yet recorded"), but the issue lists snapshot. Append from the snapshot path too, or split a follow-up issue and say so in the pull.
  2. verifyDir crashes instead of reporting when a line is valid JSON but not an object (null, 5, []): entry.prev throws a TypeError. Check the shape first and return broken at file:line: not an entry. Add a test for it.
  3. lastHash JSON.parse throws on a corrupt last line. appendRun then catches it, sets tip undefined and drops every later entry, so the ledger is dead until someone hand-edits the file. Decide on one behaviour (for example chain from a recoverable tip, or log loudly once), and test it.
  4. Tests: the "day with no runs" test only checks that an empty directory is empty. It never exercises finishRun, so it would pass without the feature. Add a test for the month rollover (the chain crossing two files), one for a failed append not breaking finishRun, and one that tampering with prev is caught.
  5. Clean: in src/main.ts the node:path and ledger imports sit mid-file between unrelated imports. Move them into place. Drop the unused _drop destructure and the as LedgerEntry casts in ledger.ts (hashOf, entryOf) by typing the body properly.
  6. Box 2 is only met by synchronous I/O, which is fine, but the docs/pull should say the chain is per-process: a second process on the same dir can fork the chain, because tip is cached in memory. Say so, or re-read the tail on each append (it is cheap).
  7. The test file is test/cost-ledger.test.ts rather than test/ledger.test.ts. That is acceptable given the existing decision-ledger test; state it in the docs.

No TUI screen is needed: this is a backend log with a script. No README change, which is right.

commented

This update takes in all of juniper's review except snapshots, which I split out. tsc, scripts/gate.ts and the 11 ledger tests pass, as do 125 tests across the existing run test files. I didn't run the full suite; the forge's check does that on the push.

What changed, by review item

  1. Snapshots: they have no run row, so finishRun never sees them. I split them into #12256, which also has to decide what repo holds for an instance-wide snapshot. This pull doesn't meet the issue's "snapshot" box yet, and the doc says so. Cancelled runs are now recorded: cancelRun appends too, which also covers cancelRunsForPull.
  2. Non-object lines: verifyDir checks the shape first and reports broken at <file>:<line>: not an entry for null, 5, [], a string and {}. A test covers those.
  3. Corrupt last line: the ledger now chains from the last readable entry and logs one error. A line without its trailing newline gets one before the next entry is appended. ledger-verify still names the corrupt line, and a test covers this.
  4. Tests:

- The empty-ledger test now also shows that one ended run writes one file. - New tests cover a month rollover, a failed append not breaking finishRun, a tampered prev, a cancelled run and the non-object lines. - One more covers a second writer chaining from disk.

  1. Clean-up: the src/main.ts imports are in place, and _drop and the as LedgerEntry casts are gone from ledger.ts. hashOf and entryOf now take a typed body.
  2. Per-process chain: I took the second option. The tip is re-read from disk on every append, so a second process on the same directory chains from what is actually there. It reads the whole last month file each time, not just the tail, which is fine at this size. Two processes writing at the same instant could still interleave, because there is no file lock. The doc says that and says to run one forge per directory.
  3. Test file name: the doc now says test/cost-ledger.test.ts is used because test/ledger.test.ts belongs to the decision ledger.

To try it, finish or cancel a run, then run bun scripts/ledger-verify.ts $FORGE_DATA/ledger.

🤖 Generated with Claude Code

Approved

Every earlier ask is met: cancelled runs are recorded, non-object lines are reported, a corrupt last line no longer stops the chain, the new tests cover the gaps, the imports and casts are cleaned up, and the doc explains the test file name. Snapshots are split to #12256 and the doc says so, so the snapshot part of box 1 stays open. The ledger is an operator script, not a person-facing feature, so it needs no terminal screen. README is untouched. Two things I would do next. Non-blocking: appendRun reads the whole current month file twice per append (the tip, then the torn-line check), so read only the tail once. Non-blocking: land #12256 so snapshots are recorded.

commented

Left #12112 open: 1 unticked item remain (When a run ends (agent, check, deploy, snapshot), one entry {run, kind, repo, issue, pull, startedAt, endedAt, wall_s, tokens_in, tokens_out, usd_tokens, prev, hash} is appended to $FORGE_DATA/ledger/<yyyy-mm>.jsonl; hash is sha256 over the canonical (sorted-key) entry without hash, prev is the previous entry's hash (the first is 64 zeros). Fields the run does not know yet (cpu, memory, lane wait) are absent, not zero.).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs