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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12275-the-forges-list-shows-each-hosted-child → main 11 commits fork
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 | ||
| 9 | 9 | ## The pages' shape (#9647) |
| 10 | 10 | |
| 11 | 11 | `/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 @@ | ||
| 155 | 155 | "src/runs/log.ts runLogTapCount", |
| 156 | 156 | "src/runs/stoppable.ts runningRun", |
| 157 | 157 | "src/spawn/child-apps.ts childRuntime", |
| 158 | "src/spawn/clients.ts monthsSettled", | |
| 158 | 159 | "src/spawn/clients.ts useChildFetch", |
| 159 | 160 | "src/spawn/door.ts usePlacement", |
| 160 | 161 | "src/spawn/door.ts useProbe", |
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 { | ||
| 219 | 219 | appendBody((prev) => snapshotEntryOf(s, prev), "snapshot"); |
| 220 | 220 | } |
| 221 | 221 | |
| 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; | |
| 223 | 234 | |
| 224 | 235 | /** Walk every month in order; the first broken line is named with the reason. */ |
| 225 | 236 | 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; | |
| 226 | 290 | 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); | |
| 228 | 298 | } |
| 229 | 299 | |
| 230 | 300 | /** The same walk over month files already in memory (a git tree, not a directory). */ |
| 231 | 301 | 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 { | |
| 232 | 309 | let prev = GENESIS; |
| 233 | 310 | let n = 0; |
| 234 | 311 | 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 | ||
| 245 | 322 | if (hashOf(body as EntryBody) !== hash) return { ok: false, at, why: "hash does not match the entry" }; |
| 246 | 323 | prev = entry.hash; |
| 247 | 324 | n++; |
| 325 | visit?.(entry); | |
| 248 | 326 | } |
| 249 | 327 | } |
| 250 | 328 | return { ok: true, entries: n }; |
src/spawn/clients.ts +78
| @@ -13,7 +13,9 @@ import { liveChildDisk, readMountinfo, type ChildDisk } from "./disk_mount.ts"; | ||
| 13 | 13 | import type { App } from "../app.ts"; |
| 14 | 14 | import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts"; |
| 15 | 15 | import { timingSafeEqual } from "node:crypto"; |
| 16 | import { join } from "node:path"; | |
| 16 | 17 | import { sha256 } from "../kernel/hash.ts"; |
| 18 | import { monthSum, TOO_LARGE, UNREADABLE, type LedgerBroken, type MonthSum } from "../core/ledger.ts"; | |
| 17 | 19 | import { creditView, usdWords } from "./credit.ts"; |
| 18 | 20 | import type { ChildAppsSummary } from "./child-apps.ts"; |
| 19 | 21 | import { cellsHere, childAwake, hostedChildApps } from "./host.ts"; |
| @@ -88,6 +90,8 @@ export interface ClientRow { | ||
| 88 | 90 | readonly stage: Stage | null; |
| 89 | 91 | /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */ |
| 90 | 92 | 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; | |
| 91 | 95 | /** The child's forge cap in dollars; null when none or unsaid. */ |
| 92 | 96 | readonly ceilingUsd: number | null; |
| 93 | 97 | /** 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 | ||
| 296 | 300 | reach: reachOf(app, c.name) ?? null, |
| 297 | 301 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, |
| 298 | 302 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 303 | month: monthOf(app, c.bodyId, now), | |
| 299 | 304 | ...creditView(app, c.name), |
| 300 | 305 | ...graceView(app, c.name, now), |
| 301 | 306 | placement: c.placement, |
| @@ -311,6 +316,79 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P | ||
| 311 | 316 | ); |
| 312 | 317 | } |
| 313 | 318 | |
| 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 | ||
| 314 | 392 | interface Served { |
| 315 | 393 | readonly rows: ClientRow[]; |
| 316 | 394 | readonly at: number; |
src/tui/screens/forges.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { useLine } from "../../spawn/usage.ts"; |
| 11 | 11 | import { timeAgo } from "../../web/ui.ts"; |
| 12 | 12 | import { card } from "../ui/widgets.ts"; |
| @@ -30,7 +30,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 30 | 30 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 31 | 31 | return rows.flatMap((r): [string, string][] => [[ |
| 32 | 32 | 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)}`] | |
| 34 | 34 | .filter((m): m is string => m !== null) |
| 35 | 35 | .join(" · "), |
| 36 | 36 | ], ...firstScreenLines(r), ...appLinkLines(r), ...addressLines(r)]); |
src/web/pages/forges.ts +11 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -45,6 +45,15 @@ function runningNote(r: ClientRow): Html | null { | ||
| 45 | 45 | >`; |
| 46 | 46 | } |
| 47 | 47 | |
| 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 | ||
| 48 | 57 | /** The crew's spend today against its cap; attention-toned from 80%. */ |
| 49 | 58 | function spendNote(r: ClientRow): Html | null { |
| 50 | 59 | const s = spendWords(r); |
| @@ -68,6 +77,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | ||
| 68 | 77 | r.made ? html`<span data-disk="${r.disk.own ? "own" : "shared"}">${diskWords(r.disk)}</span>` : null, |
| 69 | 78 | runningNote(r), |
| 70 | 79 | spendNote(r), |
| 80 | monthNote(r), | |
| 71 | 81 | creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`, |
| 72 | 82 | graceWords(r) === null ? null : html`<span data-grace>${graceWords(r)}</span>`, |
| 73 | 83 | 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 | }); |
new above
offline