Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2778-token-report-default-db.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
8 changes: 5 additions & 3 deletions core/dev/token-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
// node dev/token-report.mjs [--db <path>] [--from <date>] [--to <date>] [--tz <zone>]
// [--bot <name>] [--top <n>] [--growth <n>] [--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
Expand Down Expand Up @@ -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 <path>)`)
const from = flags.from === null ? null : parseInstant(flags.from, flags.tz, false)
const to = flags.to === null ? null : parseInstant(flags.to, flags.tz, true)
Expand Down
64 changes: 64 additions & 0 deletions core/test/token-report-default-db.test.js
Original file line number Diff line number Diff line change
@@ -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 })
}
})
3 changes: 2 additions & 1 deletion docs/subsystems/ds-bot-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions patches/ds-bot/files/dev/token-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
// node dev/token-report.mjs [--db <path>] [--from <date>] [--to <date>] [--tz <zone>]
// [--bot <name>] [--top <n>] [--growth <n>] [--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
Expand Down Expand Up @@ -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 <path>)`)
const from = flags.from === null ? null : parseInstant(flags.from, flags.tz, false)
const to = flags.to === null ? null : parseInstant(flags.to, flags.tz, true)
Expand Down
64 changes: 64 additions & 0 deletions patches/ds-bot/files/test/token-report-default-db.test.js
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading