fix(opencode-usage): read OpenCode 2 session_v2 token totals (#22391)

* fix(opencode-usage): read OpenCode 2 session_v2 token totals

OpenCode 2 copies every v1 `session` row into a new `session_v2` table and
then writes only there, so every OpenCode 2 session was invisible to the
usage scanner, which only knew `session`. Reading both tables unfiltered
would double-count the migrated rows, so the scan now builds one
deduplicated session relation where `session_v2` owns any id it shares with
the legacy table, and joins the message-level fallbacks against it too.

Bumps the usage cache schema version so existing caches rescan.

Measured against the real local opencode.db (OpenCode v2.0.12, 234 legacy
`session` rows / 252 `session_v2` rows, 18 of them v2-only):

  migrated db   before: 206 sessions, 367,419,745 tokens, $79.4704
                after:  219 sessions, 374,305,364 tokens, $79.4817
  fresh v2 db   before:   0 sessions,           0 tokens, $0
  (legacy table after:  219 sessions, 374,305,364 tokens, $79.4817
   emptied)

Fixes #15841

* fix(opencode-usage): resolve a migrated session to its fuller row

Review found two ways the fixed `session_v2`-wins precedence loses usage.

A `session_v2` without the token columns scores 0 through the source's
fallback literals, but still excluded the legacy row for every shared id, so
a token-bearing legacy `session` paired with a token-less `session_v2`
returned nothing at all — worse than before this branch. And upstream's
importer recomputes v2 totals from decoded messages, so a session whose
messages fail to decode lands *below* its frozen legacy copy, which the
"legacy is frozen, v2 is newer" rationale did not account for.

Both collapse into one rule: per id, the row with the greater token total
wins, and a tie goes to the higher-priority table. A faithful migration is a
tie, so it still resolves to `session_v2`; a v2 row that lost data no longer
erases the legacy record. This also makes `hasSessionUsageColumns`'s `some`
correct rather than merely lenient — a table without the columns can never
outrank a sibling that has them.

Measured on the real local opencode.db: zero shared ids have a legacy row
that beats its `session_v2` counterpart, so the rule is a no-op on healthy
data and only engages on the degraded shapes above.

Both new tests fail on the previous commit and pass here.
This commit is contained in:
Neil
2026-09-22 21:40:49 -07:00
committed by GitHub
parent 98a6a5325c
commit ee1c522070
4 changed files with 593 additions and 50 deletions
@@ -0,0 +1,196 @@
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
// The OpenCode schemas as the app itself creates them, written out in full
// rather than trimmed to the columns a reader names: every read probes for its
// columns, so a trimmed fixture would pass a probe the real database fails.
// Foreign keys are dropped so a generation can be built without its siblings.
const PROJECT_TABLE = `
CREATE TABLE project (
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
);
`
// OpenCode 1.17.x — and still present, frozen, after an OpenCode 2 migration.
const LEGACY_SESSION_TABLE = `
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, revert TEXT, permission TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
`
// OpenCode 2.0.x — session rows move to `session_v2`, messages to `session_message`.
const V2_SESSION_TABLE = `
CREATE TABLE session_v2 (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workspace_id TEXT, parent_id TEXT,
fork_session_id TEXT, fork_boundary TEXT, slug TEXT NOT NULL, directory TEXT NOT NULL,
path TEXT, title TEXT, version TEXT NOT NULL, share_url TEXT,
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, metadata TEXT,
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
revert TEXT, permission TEXT, agent TEXT, model TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
time_archived INTEGER, time_suspended INTEGER, resume_attempts INTEGER DEFAULT 0 NOT NULL,
time_idle INTEGER, time_viewed INTEGER, idle_outcome TEXT
);
`
// A `session_v2` that never grew the token/cost columns. Reachable on databases
// whose v2 lineage predates them, and the shape that must not erase a legacy row.
const V2_SESSION_TABLE_WITHOUT_TOKENS = `
CREATE TABLE session_v2 (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workspace_id TEXT, parent_id TEXT,
fork_session_id TEXT, fork_boundary TEXT, slug TEXT NOT NULL, directory TEXT NOT NULL,
path TEXT, title TEXT, version TEXT NOT NULL, share_url TEXT, metadata TEXT,
revert TEXT, permission TEXT, agent TEXT, model TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER
);
`
const SESSION_MESSAGE_TABLE = `
CREATE TABLE session_message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, seq INTEGER NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
`
export const OPENCODE_USAGE_FIXTURE_EPOCH_MS = 1_740_000_000_000
export type OpenCodeUsageFixtureSession = {
id: string
directory: string
title?: string
model?: string
cost?: number
tokensInput?: number
tokensOutput?: number
tokensReasoning?: number
tokensCacheRead?: number
tokensCacheWrite?: number
timeCreated?: number
timeUpdated?: number
}
/**
* Which session tables the database carries.
*
* - `v1`: OpenCode 1 only.
* - `migrated`: what an OpenCode 2 upgrade leaves behind — both tables, with the
* pre-upgrade sessions copied into `session_v2` and `session` frozen.
* - `v2-only`: `session_v2` without the legacy table at all.
*/
export type OpenCodeUsageFixtureGeneration = 'v1' | 'migrated' | 'v2-only'
export type OpenCodeUsageFixtureSpec = {
generation: OpenCodeUsageFixtureGeneration
/** Rows in the legacy `session` table; ignored for `v2-only`. */
legacySessions?: readonly OpenCodeUsageFixtureSession[]
/** Rows in `session_v2`; ignored for `v1`. */
v2Sessions?: readonly OpenCodeUsageFixtureSession[]
/** Build `session_v2` without the token/cost columns; ignored for `v1`. */
v2WithoutTokenColumns?: boolean
/** `project.worktree`, the repo root OpenCode recorded for the project. */
worktree?: string
}
const SESSION_IDENTITY_COLUMNS =
'id, project_id, slug, directory, title, version, time_created, time_updated, agent, model'
const SESSION_USAGE_COLUMNS =
'cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write'
function insertSession(
db: SyncDatabase,
table: 'session' | 'session_v2',
session: OpenCodeUsageFixtureSession,
withUsageColumns = true
): void {
const created = session.timeCreated ?? OPENCODE_USAGE_FIXTURE_EPOCH_MS
const identity = [
session.id,
session.directory,
session.title ?? 'OpenCode session',
created,
session.timeUpdated ?? created + 60_000,
session.model ?? '{"providerID":"anthropic","modelID":"claude-sonnet-4-5"}'
]
const usage = withUsageColumns
? [
session.cost ?? 0,
session.tokensInput ?? 0,
session.tokensOutput ?? 0,
session.tokensReasoning ?? 0,
session.tokensCacheRead ?? 0,
session.tokensCacheWrite ?? 0
]
: []
const columns = withUsageColumns
? `${SESSION_IDENTITY_COLUMNS}, ${SESSION_USAGE_COLUMNS}`
: SESSION_IDENTITY_COLUMNS
db.prepare(
`INSERT INTO ${table} (${columns})
VALUES (?, 'proj-1', 'slug-1', ?, ?, '1.0.0', ?, ?, 'build', ?${', ?'.repeat(usage.length)})`
).run(...identity, ...usage)
}
/**
* Create an OpenCode usage database at `dbPath` for one schema generation.
* @param dbPath - Where to create the database; parent directories are created.
* @param spec - The generation to build and the session rows to write.
*/
export function writeOpenCodeUsageDatabase(dbPath: string, spec: OpenCodeUsageFixtureSpec): void {
mkdirSync(dirname(dbPath), { recursive: true })
const db = new SyncDatabase(dbPath)
try {
db.exec(PROJECT_TABLE)
if (spec.generation !== 'v2-only') {
db.exec(LEGACY_SESSION_TABLE)
}
if (spec.generation !== 'v1') {
db.exec(spec.v2WithoutTokenColumns ? V2_SESSION_TABLE_WITHOUT_TOKENS : V2_SESSION_TABLE)
db.exec(SESSION_MESSAGE_TABLE)
}
db.prepare(
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
VALUES ('proj-1', ?, 'proj', ?, ?, '[]')`
).run(
spec.worktree ?? '/workspace/repo',
OPENCODE_USAGE_FIXTURE_EPOCH_MS,
OPENCODE_USAGE_FIXTURE_EPOCH_MS
)
if (spec.generation !== 'v2-only') {
for (const session of spec.legacySessions ?? []) {
insertSession(db, 'session', session)
}
}
if (spec.generation !== 'v1') {
for (const session of spec.v2Sessions ?? []) {
insertSession(db, 'session_v2', session, !spec.v2WithoutTokenColumns)
}
}
} finally {
db.close()
}
}