diff --git a/changelog.d/2778-token-report-default-db.fixed.md b/changelog.d/2778-token-report-default-db.fixed.md new file mode 100644 index 000000000..41c7d9408 --- /dev/null +++ b/changelog.d/2778-token-report-default-db.fixed.md @@ -0,0 +1 @@ +`core/dev/token-report.mjs` with no `--db` now reads the ledger where the host writes it, `$DSH_HOME/bot/usage.sqlite`, instead of one directory above it, so the command runs on an ordinary install instead of exiting with "no ledger at" ([#2778](https://github.com/ThinkFlowLab/FlyBot/issues/2778)). diff --git a/core/dev/token-report.mjs b/core/dev/token-report.mjs index 80051ccdd..68c09cd8f 100644 --- a/core/dev/token-report.mjs +++ b/core/dev/token-report.mjs @@ -5,8 +5,10 @@ // node dev/token-report.mjs [--db ] [--from ] [--to ] [--tz ] // [--bot ] [--top ] [--growth ] [--json] // -// `--db` defaults to `$DSH_HOME/usage.sqlite` (see src/host/usage.js). A missing file is an -// error, never an empty table: an empty report is exactly what a broken path looks like. +// `--db` defaults to `$DSH_HOME/bot/usage.sqlite`, the team folder `createRuntime` hands the +// host modules (`core/index.js:94`; `src/host/usage.js` writes the ledger there). A missing +// file is an error, never an empty table: an empty report is exactly what a broken path looks +// like. // // Fresh input, cache read and cache write are kept apart in every total. A cache read is // not a fresh input token and summing them hides which one moved (`#796`). `prompt` is the @@ -346,7 +348,7 @@ function printText(report) { function main() { const flags = parseArgs(process.argv.slice(2)) - const dbPath = flags.db ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'usage.sqlite') + const dbPath = flags.db ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'bot', 'usage.sqlite') if (!existsSync(dbPath)) die(`no ledger at ${dbPath} (pass --db )`) const from = flags.from === null ? null : parseInstant(flags.from, flags.tz, false) const to = flags.to === null ? null : parseInstant(flags.to, flags.tz, true) diff --git a/core/test/token-report-default-db.test.js b/core/test/token-report-default-db.test.js new file mode 100644 index 000000000..69c5415b5 --- /dev/null +++ b/core/test/token-report-default-db.test.js @@ -0,0 +1,64 @@ +// Red-first for P-666: token-report's default --db path does not match where the host +// writes usage.sqlite (rt.home = join(DSH_HOME, 'bot'), see core/index.js:94 and +// src/host/usage.js:327). Meant to live at core/test/, so ROOT is two levels up. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SCRIPT = join(ROOT, 'core', 'dev', 'token-report.mjs') +const at = Date.UTC(2026, 9, 20, 10, 0) + +// The ledger exactly as src/host/usage.js creates it (v3), one Bot, one turn. +function ledger(path) { + const db = new DatabaseSync(path) + try { + db.exec(` + CREATE TABLE calls (key TEXT PRIMARY KEY, session TEXT NOT NULL, time INTEGER NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, kind TEXT NOT NULL, live INTEGER NOT NULL, input INTEGER NOT NULL, cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, output INTEGER NOT NULL, harness TEXT NOT NULL, issue_pr TEXT NOT NULL, tool_calls INTEGER NOT NULL, wall_ms INTEGER NOT NULL); + CREATE TABLE marks (session TEXT PRIMARY KEY, through INTEGER NOT NULL, fold TEXT NOT NULL, sealed INTEGER NOT NULL); + CREATE TABLE owners (session TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL); + CREATE TABLE kinds (session TEXT PRIMARY KEY, kind TEXT NOT NULL); + PRAGMA user_version = 3;`) + db.prepare('INSERT INTO calls (key, session, time, provider, model, kind, live, input, cache_read, cache_write, output, harness, issue_pr, tool_calls, wall_ms) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('s1:1', 's1', at, 'test', 'test-model', 'turn', 100, 1000, 0, 5, 'default', '#799', 2, 900) + db.prepare('INSERT INTO owners (session, owner, name) VALUES (?, ?, ?)').run('s1', 's1', 'Dev') + } finally { + db.close() + } +} + +const run = (env, args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) + +test('with no --db the tool reads $DSH_HOME/bot/usage.sqlite, where the host writes it', () => { + const dshHome = mkdtempSync(join(tmpdir(), 'p666-')) + try { + mkdirSync(join(dshHome, 'bot'), { recursive: true }) + const path = join(dshHome, 'bot', 'usage.sqlite') + ledger(path) + const result = run({ DSH_HOME: dshHome }, ['--json']) + assert.equal(result.status, 0, result.stderr) + const report = JSON.parse(result.stdout) + assert.equal(report.db, path) + assert.equal(report.days.length, 1) + } finally { + rmSync(dshHome, { recursive: true, force: true }) + } +}) + +test('an explicit --db still wins over the default', () => { + const dir = mkdtempSync(join(tmpdir(), 'p666-')) + try { + const path = join(dir, 'elsewhere.sqlite') + ledger(path) + const result = run({ DSH_HOME: join(dir, 'no-such-home') }, ['--db', path, '--json']) + assert.equal(result.status, 0, result.stderr) + assert.equal(JSON.parse(result.stdout).db, path) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 3c696551b..25fcd31af 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -101,7 +101,8 @@ per-call prompt percentiles (nearest rank), the top Sessions by prompt, and each prompt growth between consecutive calls. A prompt that shrinks inside a Session is a compaction, a checkpoint or a new part, so it starts a new run instead of counting as a negative delta. Fresh input and cache read are never summed into one figure. It opens the -ledger read-only, defaults `--db` to `$DSH_HOME/usage.sqlite`, and exits non-zero naming the +ledger read-only, defaults `--db` to `$DSH_HOME/bot/usage.sqlite` (the team folder +`core/index.js:94` hands the host modules), and exits non-zero naming the path when it is missing. The report it produced and its sample are in [docs/reports/2026-10-23-token-spend.md](../../docs/reports/2026-10-23-token-spend.md); `core/test/token-report.test.js` asserts hand-computed sums on a fixture ledger, the diff --git a/patches/ds-bot/files/dev/token-report.mjs b/patches/ds-bot/files/dev/token-report.mjs index 80051ccdd..68c09cd8f 100644 --- a/patches/ds-bot/files/dev/token-report.mjs +++ b/patches/ds-bot/files/dev/token-report.mjs @@ -5,8 +5,10 @@ // node dev/token-report.mjs [--db ] [--from ] [--to ] [--tz ] // [--bot ] [--top ] [--growth ] [--json] // -// `--db` defaults to `$DSH_HOME/usage.sqlite` (see src/host/usage.js). A missing file is an -// error, never an empty table: an empty report is exactly what a broken path looks like. +// `--db` defaults to `$DSH_HOME/bot/usage.sqlite`, the team folder `createRuntime` hands the +// host modules (`core/index.js:94`; `src/host/usage.js` writes the ledger there). A missing +// file is an error, never an empty table: an empty report is exactly what a broken path looks +// like. // // Fresh input, cache read and cache write are kept apart in every total. A cache read is // not a fresh input token and summing them hides which one moved (`#796`). `prompt` is the @@ -346,7 +348,7 @@ function printText(report) { function main() { const flags = parseArgs(process.argv.slice(2)) - const dbPath = flags.db ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'usage.sqlite') + const dbPath = flags.db ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'bot', 'usage.sqlite') if (!existsSync(dbPath)) die(`no ledger at ${dbPath} (pass --db )`) const from = flags.from === null ? null : parseInstant(flags.from, flags.tz, false) const to = flags.to === null ? null : parseInstant(flags.to, flags.tz, true) diff --git a/patches/ds-bot/files/test/token-report-default-db.test.js b/patches/ds-bot/files/test/token-report-default-db.test.js new file mode 100644 index 000000000..69c5415b5 --- /dev/null +++ b/patches/ds-bot/files/test/token-report-default-db.test.js @@ -0,0 +1,64 @@ +// Red-first for P-666: token-report's default --db path does not match where the host +// writes usage.sqlite (rt.home = join(DSH_HOME, 'bot'), see core/index.js:94 and +// src/host/usage.js:327). Meant to live at core/test/, so ROOT is two levels up. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SCRIPT = join(ROOT, 'core', 'dev', 'token-report.mjs') +const at = Date.UTC(2026, 9, 20, 10, 0) + +// The ledger exactly as src/host/usage.js creates it (v3), one Bot, one turn. +function ledger(path) { + const db = new DatabaseSync(path) + try { + db.exec(` + CREATE TABLE calls (key TEXT PRIMARY KEY, session TEXT NOT NULL, time INTEGER NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, kind TEXT NOT NULL, live INTEGER NOT NULL, input INTEGER NOT NULL, cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, output INTEGER NOT NULL, harness TEXT NOT NULL, issue_pr TEXT NOT NULL, tool_calls INTEGER NOT NULL, wall_ms INTEGER NOT NULL); + CREATE TABLE marks (session TEXT PRIMARY KEY, through INTEGER NOT NULL, fold TEXT NOT NULL, sealed INTEGER NOT NULL); + CREATE TABLE owners (session TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL); + CREATE TABLE kinds (session TEXT PRIMARY KEY, kind TEXT NOT NULL); + PRAGMA user_version = 3;`) + db.prepare('INSERT INTO calls (key, session, time, provider, model, kind, live, input, cache_read, cache_write, output, harness, issue_pr, tool_calls, wall_ms) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('s1:1', 's1', at, 'test', 'test-model', 'turn', 100, 1000, 0, 5, 'default', '#799', 2, 900) + db.prepare('INSERT INTO owners (session, owner, name) VALUES (?, ?, ?)').run('s1', 's1', 'Dev') + } finally { + db.close() + } +} + +const run = (env, args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) + +test('with no --db the tool reads $DSH_HOME/bot/usage.sqlite, where the host writes it', () => { + const dshHome = mkdtempSync(join(tmpdir(), 'p666-')) + try { + mkdirSync(join(dshHome, 'bot'), { recursive: true }) + const path = join(dshHome, 'bot', 'usage.sqlite') + ledger(path) + const result = run({ DSH_HOME: dshHome }, ['--json']) + assert.equal(result.status, 0, result.stderr) + const report = JSON.parse(result.stdout) + assert.equal(report.db, path) + assert.equal(report.days.length, 1) + } finally { + rmSync(dshHome, { recursive: true, force: true }) + } +}) + +test('an explicit --db still wins over the default', () => { + const dir = mkdtempSync(join(tmpdir(), 'p666-')) + try { + const path = join(dir, 'elsewhere.sqlite') + ledger(path) + const result = run({ DSH_HOME: join(dir, 'no-such-home') }, ['--db', path, '--json']) + assert.equal(result.status, 0, result.stderr) + assert.equal(JSON.parse(result.stdout).db, path) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})