mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
A session that lived through OpenCode 2's V1 import has a row in both `session` and `session_v2`, and neither is complete. #22391 resolved the pair by ranking whole rows on one number — total token count, ties to `session_v2` — which let that number decide everything else on the row. Three consequences, each reproduced against that PR's own fixtures: - A recorded cost could be zeroed. `session_v2` wins on tokens while carrying `cost = 0`, and row parsing maps a zero cost to `null`, so a legacy row's $12.50 disappeared. Cost is re-derived by the same lossy reduce as the tokens, but only the tokens were guarded. - The token comparison decided metadata. A legacy row with more tokens supplied a stale pre-migration directory, and a legacy row with a NULL model erased the model `session_v2` had — 23 of 234 shared ids on a real migrated database have a model only on the v2 side. - Winner-takes-all is per row, so a legacy row holding the input tokens and a v2 row holding the cache reads reported one of them as zero. Metadata now comes from the generation OpenCode still writes, with older generations filling only its NULLs; usage columns take a per-column MAX. Both rows aggregate the same assistant messages, and the import can only drop messages, never invent them, so each column's MAX is a tighter lower bound on the truth than either row and can never exceed it. The relation stays exactly one row per id — the highest-priority generation holding it — every column stays `columnExists`-guarded with a SQL fallback, and a database with a single session table builds the same SQL it did before. Cache schema version 4 -> 5 so existing caches rescan.
200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
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
|
|
/** `null` writes a NULL `model`, the shape a v1 row has before the import derives one. */
|
|
model?: string | null
|
|
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 === undefined
|
|
? '{"providerID":"anthropic","modelID":"claude-sonnet-4-5"}'
|
|
: session.model
|
|
]
|
|
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()
|
|
}
|
|
}
|