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

The forges list shows each hosted child's month of cost from its own ledger, and a broken chain is shown broken, not summed #12283

Merged opened this

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

Merged

torque/forge:torque/12275-the-forges-list-shows-each-hosted-child → main 11 commits fork

  • Merged. torque/12275-the-forges-list-shows-each-hosted-child was merged into main as 9ac397e.

Review

Sign in to review this change.
8 files changed +364 −6
docs/features/forges-side-by-side.md +4
@@ -9,3 +9,7 @@ The table is for comparing; the list below it stays for what needs doing (claim
99 ## The pages' shape (#9647)
1010
1111 `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, AI account key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form keeps its fields; its key field is labelled "AI account key", as on the page (#10787).
12
13 ## This month's cost (#12275)
14
15 Each forge's line on `/forges`, in the terminal's "Forges you sent" card and in the `forges` MCP tool's `month` field says what the child spent this month, from the child's own ledger: "this month $3.75 · 2 min · 100 tokens in, 10 out". The month is the UTC calendar month, read from each entry's end time; a part no entry carried is left out, and a child with no ledger shows nothing. The ledger's hash chain is checked first: a chain that does not verify reads "ledger broken at 2026-11.jsonl:3" (as a warning badge on the page) and is never summed; a ledger that cannot be read reads "ledger unreadable", and one over 8 MiB "ledger too large to check". The ledger is never read while a page or tool request waits: the last value is served, and a read is queued (one child at a time) when it is more than five minutes old, so a child's first figure appears on the pass after the one that first lists it.
scripts/unused-exports-allow.json +1
@@ -155,6 +155,7 @@
155155 "src/runs/log.ts runLogTapCount",
156156 "src/runs/stoppable.ts runningRun",
157157 "src/spawn/child-apps.ts childRuntime",
158 "src/spawn/clients.ts monthsSettled",
158159 "src/spawn/clients.ts useChildFetch",
159160 "src/spawn/door.ts usePlacement",
160161 "src/spawn/door.ts useProbe",
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt (a person or the crew last did something there, never a heartbeat), claimedBy, claimedAt, keepBytes, archive (why it is proposed for archive, or null), running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" };
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt (a person or the crew last did something there, never a heartbeat), claimedBy, claimedAt, keepBytes, archive (why it is proposed for archive, or null), running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, month (this UTC month's spend from its own ledger, or where its chain is broken; null when it keeps none), size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" };
src/core/ledger.ts +80 −2
@@ -219,16 +219,93 @@ export function appendSnapshot(s: SnapshotEnd): void {
219219 appendBody((prev) => snapshotEntryOf(s, prev), "snapshot");
220220 }
221221
222 export type Verdict = { ok: true; entries: number } | { ok: false; at: string; why: string };
222 /** A chain that did not verify, or could not be read: where, and why. */
223 export type LedgerBroken = { readonly ok: false; readonly at: string; readonly why: string };
224
225 export type Verdict = { ok: true; entries: number } | LedgerBroken;
226
227 /** The `why` of a ledger that could not be read at all (not one that failed to verify). */
228 export const UNREADABLE = "could not be read";
229 /** The `why` of a ledger over the read cap. */
230 export const TOO_LARGE = "too large to read";
231
232 /** The most one ledger directory is read for: a child's files are the child's to write. */
233 export const READ_CAP_BYTES = 8 * 1024 * 1024;
223234
224235 /** Walk every month in order; the first broken line is named with the reason. */
225236 export function verifyDir(d: string): Verdict {
237 return scan(d);
238 }
239
240 /** One month's spend; a field is present only when some entry carried it. */
241 export interface MonthSum {
242 readonly ok: true;
243 readonly month: string;
244 readonly entries: number;
245 readonly usd_tokens?: number;
246 readonly wall_s?: number;
247 readonly tokens_in?: number;
248 readonly tokens_out?: number;
249 }
250
251 const SUMMED = ["usd_tokens", "wall_s", "tokens_in", "tokens_out"] as const;
252
253 /**
254 * Sum the entries ended in `month` (`yyyy-mm`) of the ledger in `d`, once the whole chain
255 * verifies, reading at most `capBytes`. A broken chain gives where and why, never a sum;
256 * no ledger (no directory, no month file) gives undefined, never zero; one that cannot be
257 * read says so (`UNREADABLE`). Only finite, non-negative numbers are added.
258 */
259 export function monthSum(
260 d: string,
261 month: string,
262 capBytes = READ_CAP_BYTES,
263 ): MonthSum | LedgerBroken | undefined {
264 try {
265 if (!existsSync(d) || months(d).length === 0) return undefined;
266 const sums: Partial<Record<(typeof SUMMED)[number], number>> = {};
267 let entries = 0;
268 const verdict = scan(
269 d,
270 (e) => {
271 if (typeof e.endedAt !== "string" || !e.endedAt.startsWith(month)) return;
272 entries++;
273 for (const k of SUMMED) {
274 const v = e[k];
275 if (typeof v === "number" && Number.isFinite(v) && v >= 0) sums[k] = (sums[k] ?? 0) + v;
276 }
277 },
278 capBytes,
279 );
280 if (!verdict.ok) return verdict;
281 return { ok: true, month, entries, ...sums };
282 } catch {
283 return { ok: false, at: d, why: UNREADABLE };
284 }
285 }
286
287 /** One walk over the chain, checking every link; `visit` sees each entry that holds. */
288 function scan(d: string, visit?: (e: LedgerEntry) => void, capBytes = Infinity): Verdict {
289 let read = 0;
226290 if (!existsSync(d)) return { ok: true, entries: 0 };
227 return verifyFiles(months(d).map((name) => ({ name, text: readFileSync(join(d, name), "utf8") })));
291 const files: { name: string; text: string }[] = [];
292 for (const name of months(d)) {
293 read += statSync(join(d, name)).size;
294 if (read > capBytes) return { ok: false, at: name, why: TOO_LARGE };
295 files.push({ name, text: readFileSync(join(d, name), "utf8") });
296 }
297 return walk(files, visit);
228298 }
229299
230300 /** The same walk over month files already in memory (a git tree, not a directory). */
231301 export function verifyFiles(files: readonly { name: string; text: string }[]): Verdict {
302 return walk(files);
303 }
304
305 function walk(
306 files: readonly { name: string; text: string }[],
307 visit?: (e: LedgerEntry) => void,
308 ): Verdict {
232309 let prev = GENESIS;
233310 let n = 0;
234311 const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -245,6 +322,7 @@ export function verifyFiles(files: readonly { name: string; text: string }[]): V
245322 if (hashOf(body as EntryBody) !== hash) return { ok: false, at, why: "hash does not match the entry" };
246323 prev = entry.hash;
247324 n++;
325 visit?.(entry);
248326 }
249327 }
250328 return { ok: true, entries: n };
src/spawn/clients.ts +78
@@ -13,7 +13,9 @@ import { liveChildDisk, readMountinfo, type ChildDisk } from "./disk_mount.ts";
1313 import type { App } from "../app.ts";
1414 import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts";
1515 import { timingSafeEqual } from "node:crypto";
16 import { join } from "node:path";
1617 import { sha256 } from "../kernel/hash.ts";
18 import { monthSum, TOO_LARGE, UNREADABLE, type LedgerBroken, type MonthSum } from "../core/ledger.ts";
1719 import { creditView, usdWords } from "./credit.ts";
1820 import type { ChildAppsSummary } from "./child-apps.ts";
1921 import { cellsHere, childAwake, hostedChildApps } from "./host.ts";
@@ -88,6 +90,8 @@ export interface ClientRow {
8890 readonly stage: Stage | null;
8991 /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */
9092 readonly spendTodayUsd: number | null;
93 /** This month's spend from the child's own ledger; a broken chain says where; null when none. */
94 readonly month?: MonthSum | LedgerBroken | null;
9195 /** The child's forge cap in dollars; null when none or unsaid. */
9296 readonly ceilingUsd: number | null;
9397 /** The dollars this forge lends it on its own model credential, and what it has spent; null when none is lent. */
@@ -296,6 +300,7 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P
296300 reach: reachOf(app, c.name) ?? null,
297301 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
298302 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
303 month: monthOf(app, c.bodyId, now),
299304 ...creditView(app, c.name),
300305 ...graceView(app, c.name, now),
301306 placement: c.placement,
@@ -311,6 +316,79 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P
311316 );
312317 }
313318
319 type Month = NonNullable<ClientRow["month"]>;
320 interface Months {
321 readonly cache: Map<string, { at: number; value: Month | null }>;
322 readonly queued: Set<string>;
323 readonly jobs: Array<() => void>;
324 running: Promise<void> | null;
325 }
326 const months = new WeakMap<App, Months>();
327 /** A child's ledger is read again at most this often. */
328 export const ledgerWindow = { ms: 5 * 60_000 };
329
330 /**
331 * This month's sum from the child's ledger under `<data>/children/<bodyId>/ledger`. It never
332 * reads on the request: it answers the last value (null until the first read has come) and
333 * queues a read, one child per turn of the event loop, when that value is older than the window.
334 */
335 function monthOf(app: App, bodyId: string, now: number): Month | null {
336 let state = months.get(app);
337 if (!state) months.set(app, (state = { cache: new Map(), queued: new Set(), jobs: [], running: null }));
338 const s = state;
339 const month = new Date(now).toISOString().slice(0, 7);
340 const key = `${bodyId}:${month}`;
341 const have = s.cache.get(key);
342 if (have !== undefined && now - have.at < ledgerWindow.ms) return have.value;
343 if (!s.queued.has(key)) {
344 s.queued.add(key);
345 s.jobs.push(() => {
346 s.cache.set(key, { at: Date.now(), value: monthSum(join(childDirOf(app, bodyId), "ledger"), month) ?? null });
347 s.queued.delete(key);
348 });
349 s.running ??= (async () => {
350 while (s.jobs.length > 0) {
351 await new Promise((r) => setImmediate(r));
352 s.jobs.shift()?.();
353 }
354 })().finally(() => {
355 s.running = null;
356 });
357 }
358 return have?.value ?? null;
359 }
360
361 /** Resolves when every ledger read queued so far is done (the suite waits on it). */
362 export async function monthsSettled(app: App): Promise<void> {
363 for (let r = months.get(app)?.running; r; r = months.get(app)?.running) await r;
364 }
365
366 /** "1.5 h" from an hour up, whole minutes under it. */
367 function spanWords(seconds: number): string {
368 if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)} h`;
369 return seconds < 60 ? "under a minute" : `${Math.round(seconds / 60)} min`;
370 }
371
372 /**
373 * "this month $12.40 · 1.5 h · 100 tokens in, 10 out" (the UTC month), or a plain sentence when
374 * the ledger cannot be trusted; `broken` is then true, so a page shows it as a warning.
375 */
376 export function monthWords(r: Pick<ClientRow, "month">): { words: string; broken: boolean } | null {
377 const m = r.month;
378 if (m === undefined || m === null) return null;
379 if (!m.ok) {
380 if (m.why === UNREADABLE) return { words: "ledger unreadable", broken: true };
381 if (m.why === TOO_LARGE) return { words: "ledger too large to check", broken: true };
382 return { words: `ledger broken at ${m.at}`, broken: true };
383 }
384 const parts = [
385 m.usd_tokens === undefined ? null : `$${m.usd_tokens.toFixed(2)}`,
386 m.wall_s === undefined ? null : spanWords(m.wall_s),
387 m.tokens_in === undefined && m.tokens_out === undefined ? null : `${m.tokens_in ?? 0} tokens in, ${m.tokens_out ?? 0} out`,
388 ].filter((p): p is string => p !== null);
389 return { words: `this month ${parts.length === 0 ? "no spend" : parts.join(" · ")}`, broken: false };
390 }
391
314392 interface Served {
315393 readonly rows: ClientRow[];
316394 readonly at: number;
src/tui/screens/forges.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
9 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
9 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, graceWords, runningWords, spendWords, monthWords, type ClientRow } from "../../spawn/clients.ts";
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
@@ -30,7 +30,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
3030 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
3131 return rows.flatMap((r): [string, string][] => [[
3232 r.name,
33 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.made ? (r.lastActivityAt === null ? "no person has done anything here" : `a person last did something ${timeAgo(r.lastActivityAt)}`) : null, claimedWords(r), keepWords(r), r.archive === null || r.archive === undefined ? null : `proposed for archive: ${r.archive.why}`, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
33 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, monthWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.made ? (r.lastActivityAt === null ? "no person has done anything here" : `a person last did something ${timeAgo(r.lastActivityAt)}`) : null, claimedWords(r), keepWords(r), r.archive === null || r.archive === undefined ? null : `proposed for archive: ${r.archive.why}`, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
3434 .filter((m): m is string => m !== null)
3535 .join(" · "),
3636 ], ...firstScreenLines(r), ...appLinkLines(r), ...addressLines(r)]);
src/web/pages/forges.ts +11 −1
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, memoryOf, servedRows, dropServed, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
10 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, memoryOf, servedRows, dropServed, creditWords, graceWords, runningWords, spendWords, monthWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -45,6 +45,15 @@ function runningNote(r: ClientRow): Html | null {
4545 >`;
4646 }
4747
48 /** This month's cost from the child's ledger; one that cannot be trusted reads as a warning. */
49 function monthNote(r: ClientRow): Html | null {
50 const m = monthWords(r);
51 if (m === null) return null;
52 return m.broken
53 ? html`<span data-month="broken">${badge(m.words, { tone: "attn" })}</span>`
54 : html`<span data-month="ok">${m.words}</span>`;
55 }
56
4857 /** The crew's spend today against its cap; attention-toned from 80%. */
4958 function spendNote(r: ClientRow): Html | null {
5059 const s = spendWords(r);
@@ -68,6 +77,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
6877 r.made ? html`<span data-disk="${r.disk.own ? "own" : "shared"}">${diskWords(r.disk)}</span>` : null,
6978 runningNote(r),
7079 spendNote(r),
80 monthNote(r),
7181 creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`,
7282 graceWords(r) === null ? null : html`<span data-grace>${graceWords(r)}</span>`,
7383 r.stage === null
test/forges-month-ledger.test.ts added +187
@@ -0,0 +1,187 @@
1 /**
2 * Each hosted child's month of cost comes from its own ledger: summed when the chain
3 * verifies, shown broken at the line when it does not, nothing when there is none.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { childDirOf, listChildren, spawnForge } from "../src/actions/spawn.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { GENESIS, hashOf, monthSum, type LedgerEntry } from "../src/core/ledger.ts";
14 import { clientRows as rowsNow, ledgerWindow, monthsSettled, monthWords } from "../src/spawn/clients.ts";
15 import { forgeRow } from "../src/web/pages/forges.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { forgeLines } from "../src/tui/screens/forges.ts";
19 import { hostedPlacement } from "../src/spawn/placement.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dir = "";
25 let parent: App;
26 let servers: Servers;
27 let aliceId = "";
28 const month = new Date().toISOString().slice(0, 7);
29
30 function chain(items: Array<Partial<LedgerEntry>>): string[] {
31 let prev = GENESIS;
32 return items.map((o, i) => {
33 const body = { run: `r${i}`, kind: "build", repo: "x", endedAt: `${month}-02T00:00:00.000Z`, ...o, prev } as Omit<LedgerEntry, "hash">;
34 const hash = hashOf(body);
35 prev = hash;
36 return JSON.stringify({ ...body, hash });
37 });
38 }
39
40 function writeLedger(bodyId: string, lines: string[]): string {
41 const d = join(childDirOf(parent, bodyId), "ledger");
42 mkdirSync(d, { recursive: true });
43 writeFileSync(join(d, `${month}.jsonl`), `${lines.join("\n")}\n`);
44 return join(d, `${month}.jsonl`);
45 }
46
47 /** The rows once the background reads have come: the first call queues them, the second sees. */
48 async function clientRows(app: App) {
49 await rowsNow(app);
50 await monthsSettled(app);
51 return rowsNow(app);
52 }
53
54 const body = async (name: string): Promise<string> => (await listChildren(parent)).find((c) => c.name === name)!.bodyId;
55
56 beforeAll(async () => {
57 ledgerWindow.ms = 0;
58 dir = await mkdtemp(join(tmpdir(), "month-ledger-"));
59 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
60 const made = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
61 if (!made.ok) throw new Error("user");
62 aliceId = made.value.id;
63 for (const name of ["one", "two", "none"]) {
64 const s = await spawnForge(
65 parent,
66 made.value,
67 { name, org: name, apps: [], carryBriefs: false, carrySubscriptions: false },
68 { catalog: [], briefs: [], subscriptions: [] },
69 {
70 placement: hostedPlacement({ host: "forge.test" }),
71 request: new Request("https://forge.test/spawn", { method: "POST", headers: { origin: "https://forge.test" } }),
72 url: new URL("https://forge.test/spawn"),
73 },
74 );
75 if (!s.ok) throw new Error(JSON.stringify(s.error));
76 }
77 });
78
79 afterAll(async () => {
80 parent.close();
81 await rm(dir, { recursive: true, force: true });
82 });
83
84 test("two children with known entries sum; a child with no ledger shows nothing", async () => {
85 writeLedger(await body("one"), chain([
86 { usd_tokens: 1.25, wall_s: 60, tokens_in: 100, tokens_out: 10 },
87 { usd_tokens: 2.5, wall_s: 30 },
88 { usd_tokens: 99, endedAt: "2020-01-01T00:00:00.000Z" },
89 ]));
90 writeLedger(await body("two"), chain([{ usd_tokens: 0.5, wall_s: 3600 }]));
91 const rows = await clientRows(parent);
92 const one = rows.find((r) => r.name === "one")!;
93 expect(one.month).toMatchObject({ ok: true, usd_tokens: 3.75, wall_s: 90, tokens_in: 100, tokens_out: 10 });
94 expect(monthWords(one)?.words).toBe("this month $3.75 · 2 min · 100 tokens in, 10 out");
95 expect(rows.find((r) => r.name === "two")!.month).toMatchObject({ ok: true, usd_tokens: 0.5, wall_s: 3600 });
96 const none = rows.find((r) => r.name === "none")!;
97 expect(none.month).toBeNull();
98 expect(monthWords(none)).toBeNull();
99 });
100
101 test("one tampered field is shown broken at that line, with no sum", async () => {
102 const lines = chain([{ usd_tokens: 1 }, { usd_tokens: 2 }, { usd_tokens: 3 }]);
103 lines[1] = lines[1]!.replace('"usd_tokens":2', '"usd_tokens":0.01');
104 const file = writeLedger(await body("two"), lines);
105 expect(readFileSync(file, "utf8")).toContain("0.01");
106 const rows = await clientRows(parent);
107 const two = rows.find((r) => r.name === "two")!;
108 expect(two.month).toMatchObject({ ok: false, at: `${month}.jsonl:2` });
109 expect(monthWords(two)?.words).toBe(`ledger broken at ${month}.jsonl:2`);
110 expect(forgeLines([two]).map((l) => l[1]).join("\n")).toContain(`ledger broken at ${month}.jsonl:2`);
111 expect(forgeLines([two]).map((l) => l[1]).join("\n")).not.toContain("$6");
112 });
113
114 test("reading is capped, and untrusted numbers are not summed", () => {
115 const d = join(dir, "cap");
116 mkdirSync(d);
117 writeFileSync(join(d, `${month}.jsonl`), `${chain([{ usd_tokens: 1 }]).join("\n")}\n`);
118 expect(monthSum(d, month, 10)).toMatchObject({ ok: false, why: "too large to read" });
119 writeFileSync(join(d, `${month}.jsonl`), `${chain([{ usd_tokens: -5 }, { usd_tokens: "9" as unknown as number }]).join("\n")}\n`);
120 expect(monthSum(d, month)).toEqual({ ok: true, month, entries: 2 });
121 });
122
123 test("the ledger is read at most once a window, and a change after it is picked up", async () => {
124 const id = await body("one");
125 writeLedger(id, chain([{ usd_tokens: 1 }]));
126 await clientRows(parent); // read once with no window
127 await monthsSettled(parent); // and let the re-read that call queued finish
128 ledgerWindow.ms = 60_000;
129 try {
130 const first = (await rowsNow(parent)).find((r) => r.name === "one")!;
131 expect(first.month).toMatchObject({ usd_tokens: 1 });
132 writeLedger(id, chain([{ usd_tokens: 1 }, { usd_tokens: 4 }]));
133 const within = (await rowsNow(parent)).find((r) => r.name === "one")!;
134 await monthsSettled(parent);
135 expect((await rowsNow(parent)).find((r) => r.name === "one")!.month).toMatchObject({ usd_tokens: 1 });
136 expect(within.month).toMatchObject({ usd_tokens: 1 });
137 ledgerWindow.ms = 0; // the window has passed: the old value is served while the read runs
138 expect((await rowsNow(parent)).find((r) => r.name === "one")!.month).toMatchObject({ usd_tokens: 1 });
139 await monthsSettled(parent);
140 expect((await rowsNow(parent)).find((r) => r.name === "one")!.month).toMatchObject({ usd_tokens: 5 });
141 } finally {
142 ledgerWindow.ms = 0;
143 }
144 });
145
146 test("an unreadable ledger says so; plain words for short spans and tokens", async () => {
147 const id = await body("none");
148 const d = join(childDirOf(parent, id), "ledger");
149 mkdirSync(join(d, `${month}.jsonl`), { recursive: true }); // a directory where a month file should be
150 const row = (await clientRows(parent)).find((r) => r.name === "none")!;
151 expect(row.month).toMatchObject({ ok: false });
152 expect(monthWords(row)).toEqual({ words: "ledger unreadable", broken: true });
153 expect(monthWords({ month: { ok: true, month, entries: 1, wall_s: 20 } })?.words).toBe("this month under a minute");
154 expect(monthWords({ month: { ok: true, month, entries: 1, wall_s: 5400 } })?.words).toBe("this month 1.5 h");
155 });
156
157 test("the /forges row shows the month, and a broken chain as a warning badge", async () => {
158 const rows = await clientRows(parent);
159 const ok = String(forgeRow(rows.find((r) => r.name === "one")!, Date.now()));
160 expect(ok).toContain(`<span data-month="ok">this month $5.00</span>`);
161 const bad = String(forgeRow(rows.find((r) => r.name === "two")!, Date.now()));
162 expect(bad).toMatch(/data-month="broken"[^]*ledger broken at/);
163 expect(bad).not.toContain(`data-month="ok"`);
164 });
165
166 test("the MCP forges tool carries month on each row", async () => {
167 servers = await serve(parent);
168 const tok = createToken(parent.db, aliceId, "rw", "rw").token;
169 try {
170 const call = async () => {
171 const res = await fetch(`${servers.url}/mcp`, {
172 method: "POST",
173 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
174 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }),
175 });
176 const j = (await res.json()) as any;
177 return Object.fromEntries(JSON.parse(String(j.result.content[0].text)).forges.map((f: any) => [f.name, f]));
178 };
179 await call();
180 await monthsSettled(parent);
181 const by = await call();
182 expect(by["one"].month).toMatchObject({ ok: true, usd_tokens: 5 });
183 expect(by["two"].month).toMatchObject({ ok: false, at: `${month}.jsonl:2` });
184 } finally {
185 servers.stop();
186 }
187 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs