mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { readdir, realpath, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { listOpenCodeDatabaseFiles } from '../opencode/opencode-database-files'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import { getUsageHistoryRetainedBytes, UsageHistoryScanBudget } from '../usage-history-scan-budget'
|
||||
import { iterateOpenCodeUsageRows, type OpenCodeUsageRow } from './sqlite-usage-row-stream'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
OpenCodeUsageDailyAggregate,
|
||||
@@ -29,6 +27,34 @@ export type OpenCodeUsageWorktreeRef = {
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type OpenCodeUsageRow = {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number
|
||||
time_updated: number | null
|
||||
data: string
|
||||
directory: string | null
|
||||
title: string | null
|
||||
worktree: string | null
|
||||
session_model: string | null
|
||||
}
|
||||
|
||||
type OpenCodeSessionUsageRow = {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number
|
||||
time_updated: number | null
|
||||
directory: string | null
|
||||
title: string | null
|
||||
worktree: string | null
|
||||
session_model: string | null
|
||||
cost: number
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
}
|
||||
|
||||
const YIELD_EVERY_DATABASES = 2
|
||||
|
||||
function ensureNumber(value: unknown): number {
|
||||
@@ -85,7 +111,11 @@ export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
}
|
||||
|
||||
try {
|
||||
return (await listOpenCodeDatabaseFiles(getOpenCodeDataDirectory())).paths
|
||||
const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(getOpenCodeDataDirectory(), entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -118,6 +148,140 @@ async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function getProjectJoin(db: Database.Database): string {
|
||||
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
|
||||
? 'LEFT JOIN project p ON p.id = s.project_id'
|
||||
: 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0'
|
||||
}
|
||||
|
||||
function getSessionModelSelect(db: Database.Database): string {
|
||||
return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model'
|
||||
}
|
||||
|
||||
function getAssistantSessionMessageCount(db: Database.Database): number {
|
||||
if (!tableExists(db, 'session_message')) {
|
||||
return 0
|
||||
}
|
||||
const assistantPredicate = columnExists(db, 'session_message', 'type')
|
||||
? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL"
|
||||
: "json_extract(data, '$.tokens.input') IS NOT NULL"
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
|
||||
.get() as { count?: number } | undefined
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function canReadSessionUsageRows(db: Database.Database): boolean {
|
||||
if (!tableExists(db, 'session')) {
|
||||
return false
|
||||
}
|
||||
return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every(
|
||||
(columnName) => columnExists(db, 'session', columnName)
|
||||
)
|
||||
}
|
||||
|
||||
function getSessionUsageRowCount(db: Database.Database): number {
|
||||
if (!canReadSessionUsageRows(db)) {
|
||||
return 0
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM session
|
||||
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
|
||||
)
|
||||
.get() as { count?: number } | undefined
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
|
||||
const projectJoin = getProjectJoin(db)
|
||||
const sessionModelSelect = getSessionModelSelect(db)
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect},
|
||||
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
|
||||
FROM session s
|
||||
${projectJoin}
|
||||
WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0
|
||||
ORDER BY s.time_created, s.id`
|
||||
)
|
||||
.all() as OpenCodeSessionUsageRow[]
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
session_id: row.session_id,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
directory: row.directory,
|
||||
title: row.title,
|
||||
worktree: row.worktree,
|
||||
session_model: row.session_model,
|
||||
data: JSON.stringify({
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
reasoning: row.tokens_reasoning,
|
||||
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
|
||||
cache: {
|
||||
read: row.tokens_cache_read,
|
||||
write: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] {
|
||||
if (!tableExists(db, 'session')) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Why: newer OpenCode DBs maintain session-level token/cost totals. Reading
|
||||
// one aggregate row per session is faster than parsing every message blob.
|
||||
if (getSessionUsageRowCount(db) > 0) {
|
||||
return selectSessionUsageRows(db)
|
||||
}
|
||||
|
||||
const projectJoin = getProjectJoin(db)
|
||||
const sessionModelSelect = getSessionModelSelect(db)
|
||||
|
||||
if (getAssistantSessionMessageCount(db) > 0) {
|
||||
const assistantPredicate = columnExists(db, 'session_message', 'type')
|
||||
? "sm.type = 'assistant'"
|
||||
: "json_extract(sm.data, '$.tokens.input') IS NOT NULL"
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect}
|
||||
FROM session_message sm
|
||||
JOIN session s ON s.id = sm.session_id
|
||||
${projectJoin}
|
||||
WHERE ${assistantPredicate}
|
||||
ORDER BY sm.time_created, sm.id`
|
||||
)
|
||||
.all() as OpenCodeUsageRow[]
|
||||
}
|
||||
|
||||
if (!tableExists(db, 'message')) {
|
||||
return []
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect}
|
||||
FROM message m
|
||||
JOIN session s ON s.id = m.session_id
|
||||
${projectJoin}
|
||||
WHERE json_extract(m.data, '$.role') = 'assistant'
|
||||
ORDER BY m.time_created, m.id`
|
||||
)
|
||||
.all() as OpenCodeUsageRow[]
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
@@ -677,71 +841,11 @@ function mergeDailyAggregates(
|
||||
}
|
||||
}
|
||||
|
||||
function claimOpenCodeUsageProjection(
|
||||
budget: UsageHistoryScanBudget,
|
||||
sessions: readonly OpenCodeUsageSession[],
|
||||
dailyAggregates: readonly OpenCodeUsageDailyAggregate[]
|
||||
): void {
|
||||
for (const session of sessions) {
|
||||
budget.claimProjection(
|
||||
getUsageHistoryRetainedBytes([
|
||||
session.sessionId,
|
||||
session.firstTimestamp,
|
||||
session.lastTimestamp,
|
||||
session.primaryModel,
|
||||
session.primaryProjectLabel,
|
||||
session.primaryWorktreeId,
|
||||
session.primaryRepoId
|
||||
])
|
||||
)
|
||||
for (const location of session.locationBreakdown) {
|
||||
budget.claimProjection(
|
||||
getUsageHistoryRetainedBytes([
|
||||
location.locationKey,
|
||||
location.projectLabel,
|
||||
location.repoId,
|
||||
location.worktreeId
|
||||
])
|
||||
)
|
||||
}
|
||||
for (const model of session.modelBreakdown) {
|
||||
budget.claimProjection(getUsageHistoryRetainedBytes([model.modelKey, model.modelLabel]))
|
||||
}
|
||||
for (const locationModel of session.locationModelBreakdown) {
|
||||
budget.claimProjection(
|
||||
getUsageHistoryRetainedBytes([
|
||||
locationModel.locationKey,
|
||||
locationModel.modelKey,
|
||||
locationModel.modelLabel,
|
||||
locationModel.repoId,
|
||||
locationModel.worktreeId
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const daily of dailyAggregates) {
|
||||
budget.claimProjection(
|
||||
getUsageHistoryRetainedBytes([
|
||||
daily.day,
|
||||
daily.model,
|
||||
daily.projectKey,
|
||||
daily.projectLabel,
|
||||
daily.repoId,
|
||||
daily.worktreeId
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseOpenCodeUsageDatabase(
|
||||
dbPath: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[],
|
||||
options: {
|
||||
claimSession?: (sessionId: string) => boolean
|
||||
budget?: UsageHistoryScanBudget
|
||||
} = {}
|
||||
options: { claimSession?: (sessionId: string) => boolean } = {}
|
||||
): Promise<OpenCodeUsagePersistedDatabase> {
|
||||
const budget = options.budget ?? new UsageHistoryScanBudget()
|
||||
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
@@ -749,7 +853,7 @@ export async function parseOpenCodeUsageDatabase(
|
||||
const events: OpenCodeUsageAttributedEvent[] = []
|
||||
const claimedBySessionId = new Map<string, boolean>()
|
||||
let hasDeferredClaims = false
|
||||
for (const row of iterateOpenCodeUsageRows(db)) {
|
||||
for (const row of selectUsageRows(db)) {
|
||||
const parsed = parseOpenCodeUsageRow(row)
|
||||
if (!parsed) {
|
||||
continue
|
||||
@@ -758,7 +862,6 @@ export async function parseOpenCodeUsageDatabase(
|
||||
// each session must be counted from exactly one database (#8006).
|
||||
let owned = claimedBySessionId.get(parsed.sessionId)
|
||||
if (owned === undefined) {
|
||||
budget.claimOwnershipKey(parsed.sessionId)
|
||||
owned = options.claimSession ? options.claimSession(parsed.sessionId) : true
|
||||
claimedBySessionId.set(parsed.sessionId, owned)
|
||||
}
|
||||
@@ -768,27 +871,12 @@ export async function parseOpenCodeUsageDatabase(
|
||||
}
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
|
||||
if (attributed) {
|
||||
budget.claimRecord(
|
||||
getUsageHistoryRetainedBytes([
|
||||
attributed.sessionId,
|
||||
attributed.timestamp,
|
||||
attributed.cwd,
|
||||
attributed.model,
|
||||
attributed.day,
|
||||
attributed.projectKey,
|
||||
attributed.projectLabel,
|
||||
attributed.repoId,
|
||||
attributed.worktreeId
|
||||
])
|
||||
)
|
||||
events.push(attributed)
|
||||
}
|
||||
}
|
||||
const aggregates = aggregateOpenCodeUsage(events)
|
||||
claimOpenCodeUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates)
|
||||
return {
|
||||
...processedDatabase,
|
||||
...aggregates,
|
||||
...aggregateOpenCodeUsage(events),
|
||||
ownedSessionIds: [...claimedBySessionId.entries()]
|
||||
.filter(([, owned]) => owned)
|
||||
.map(([sessionId]) => sessionId),
|
||||
@@ -801,14 +889,12 @@ export async function parseOpenCodeUsageDatabase(
|
||||
|
||||
export async function scanOpenCodeUsageDatabases(
|
||||
worktrees: OpenCodeUsageWorktreeRef[],
|
||||
previousProcessedDatabases: OpenCodeUsagePersistedDatabase[],
|
||||
options: { budget?: UsageHistoryScanBudget } = {}
|
||||
previousProcessedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
): Promise<{
|
||||
processedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
}> {
|
||||
const budget = options.budget ?? new UsageHistoryScanBudget()
|
||||
const dbPaths = await listOpenCodeDatabases()
|
||||
const previousByPath = new Map(
|
||||
previousProcessedDatabases.map((database) => [database.path, database])
|
||||
@@ -879,12 +965,7 @@ export async function scanOpenCodeUsageDatabases(
|
||||
const sessionOwnerById = new Map<string, string>()
|
||||
for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) {
|
||||
const previous = reusedByPath.get(dbPath)
|
||||
for (const session of previous?.sessions ?? []) {
|
||||
budget.claimRecords(session.eventCount)
|
||||
}
|
||||
claimOpenCodeUsageProjection(budget, previous?.sessions ?? [], previous?.dailyAggregates ?? [])
|
||||
for (const sessionId of previous?.ownedSessionIds ?? []) {
|
||||
budget.claimOwnershipKey(sessionId)
|
||||
if (!sessionOwnerById.has(sessionId)) {
|
||||
sessionOwnerById.set(sessionId, dbPath)
|
||||
}
|
||||
@@ -895,7 +976,6 @@ export async function scanOpenCodeUsageDatabases(
|
||||
const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority)
|
||||
for (const [index, dbPath] of orderedPathsToParse.entries()) {
|
||||
const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, {
|
||||
budget,
|
||||
claimSession: (sessionId) => {
|
||||
const owner = sessionOwnerById.get(sessionId)
|
||||
if (owner !== undefined && owner !== dbPath) {
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { UsageHistoryScanBudget, UsageHistoryScanCapacityError } from '../usage-history-scan-budget'
|
||||
import { parseOpenCodeUsageDatabase } from './scanner'
|
||||
import {
|
||||
iterateOpenCodeUsageRows,
|
||||
OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES
|
||||
} from './sqlite-usage-row-stream'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function createDatabase(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-retention-'))
|
||||
tempDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
const db = new Database(path)
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
cost REAL,
|
||||
tokens_input INTEGER,
|
||||
tokens_output INTEGER,
|
||||
tokens_reasoning INTEGER,
|
||||
tokens_cache_read INTEGER,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
`)
|
||||
return { db, path }
|
||||
}
|
||||
|
||||
function insertSession(db: Database.Database, id: string, title = '', inputTokens = 1): void {
|
||||
db.prepare(
|
||||
`INSERT INTO session (
|
||||
id, directory, title, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(id, null, title, 0, inputTokens, 0, 0, 0, 1_777_777_700_000, 1_777_777_800_000)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('OpenCode SQLite usage row retention', () => {
|
||||
it('preserves an exact-boundary row', () => {
|
||||
const { db } = createDatabase()
|
||||
const title = 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 2)
|
||||
insertSession(db, 's', title)
|
||||
|
||||
const rows = [...iterateOpenCodeUsageRows(db)]
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]?.id).toBe('s')
|
||||
expect(rows[0]?.title).toBe(title)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('rejects a row one byte above the retained-text limit', () => {
|
||||
const { db } = createDatabase()
|
||||
insertSession(db, 's', 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 1))
|
||||
|
||||
expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError(
|
||||
new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES)
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('rejects oversized legacy JSON before asking SQLite to parse it', () => {
|
||||
const { db } = createDatabase()
|
||||
insertSession(db, 's', '', 0)
|
||||
db.exec(`
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
data TEXT
|
||||
);
|
||||
`)
|
||||
const data = `{"role":"assistant","padding":"${'x'.repeat(
|
||||
OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES
|
||||
)}"}`
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('m', 's', 1, 1, data)
|
||||
|
||||
expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError(
|
||||
new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES)
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('shares the history record budget across streamed rows', async () => {
|
||||
const { db, path } = createDatabase()
|
||||
insertSession(db, 'session-1')
|
||||
insertSession(db, 'session-2')
|
||||
db.close()
|
||||
const budget = new UsageHistoryScanBudget({
|
||||
records: 1,
|
||||
ownershipKeys: 4,
|
||||
retainedBytes: 16 * 1024 * 1024
|
||||
})
|
||||
|
||||
await expect(parseOpenCodeUsageDatabase(path, [], { budget })).rejects.toMatchObject({
|
||||
resource: 'records',
|
||||
limit: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,238 +0,0 @@
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import { UsageHistoryScanCapacityError } from '../usage-history-scan-budget'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
|
||||
export const OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES = 4 * 1024 * 1024
|
||||
|
||||
export type OpenCodeUsageRow = {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number
|
||||
time_updated: number | null
|
||||
data: string
|
||||
directory: string | null
|
||||
title: string | null
|
||||
worktree: string | null
|
||||
session_model: string | null
|
||||
}
|
||||
|
||||
type OpenCodeSessionUsageRow = Omit<OpenCodeUsageRow, 'data'> & {
|
||||
cost: number
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
}
|
||||
|
||||
function retainedTextBytesSql(expressions: readonly string[]): string {
|
||||
return expressions
|
||||
.map((expression) => `length(CAST(COALESCE(${expression}, '') AS BLOB))`)
|
||||
.join(' + ')
|
||||
}
|
||||
|
||||
function assertNoOversizedRows(
|
||||
db: Database.Database,
|
||||
fromSql: string,
|
||||
whereSql: string,
|
||||
rowBytesSql: string
|
||||
): void {
|
||||
const oversized = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
${fromSql}
|
||||
WHERE ${whereSql}
|
||||
AND (${rowBytesSql}) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES}
|
||||
LIMIT 1`
|
||||
)
|
||||
.get()
|
||||
if (oversized) {
|
||||
throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoOversizedJsonCandidates(
|
||||
db: Database.Database,
|
||||
table: 'message' | 'session_message',
|
||||
candidateWhereSql: string
|
||||
): void {
|
||||
const oversized = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM ${table}
|
||||
WHERE ${candidateWhereSql}
|
||||
AND length(CAST(COALESCE(data, '') AS BLOB)) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES}
|
||||
LIMIT 1`
|
||||
)
|
||||
.get()
|
||||
if (oversized) {
|
||||
throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
function boundedJsonPredicate(dataExpression: string, predicate: string): string {
|
||||
return `CASE
|
||||
WHEN length(CAST(COALESCE(${dataExpression}, '') AS BLOB)) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES}
|
||||
THEN (${predicate})
|
||||
ELSE 0
|
||||
END`
|
||||
}
|
||||
|
||||
function getProjectJoin(db: Database.Database): string {
|
||||
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
|
||||
? 'LEFT JOIN project p ON p.id = s.project_id'
|
||||
: 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0'
|
||||
}
|
||||
|
||||
function getSessionModelSelect(db: Database.Database): string {
|
||||
return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model'
|
||||
}
|
||||
|
||||
function getAssistantSessionMessageCount(db: Database.Database): number {
|
||||
if (!tableExists(db, 'session_message')) {
|
||||
return 0
|
||||
}
|
||||
const hasType = columnExists(db, 'session_message', 'type')
|
||||
assertNoOversizedJsonCandidates(db, 'session_message', hasType ? "type = 'assistant'" : '1 = 1')
|
||||
const jsonPredicate = boundedJsonPredicate(
|
||||
'data',
|
||||
"json_extract(data, '$.tokens.input') IS NOT NULL"
|
||||
)
|
||||
const assistantPredicate = hasType ? `type = 'assistant' AND ${jsonPredicate}` : jsonPredicate
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
|
||||
.get() as { count?: number } | undefined
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function canReadSessionUsageRows(db: Database.Database): boolean {
|
||||
if (!tableExists(db, 'session')) {
|
||||
return false
|
||||
}
|
||||
return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every(
|
||||
(columnName) => columnExists(db, 'session', columnName)
|
||||
)
|
||||
}
|
||||
|
||||
function getSessionUsageRowCount(db: Database.Database): number {
|
||||
if (!canReadSessionUsageRows(db)) {
|
||||
return 0
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM session
|
||||
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
|
||||
)
|
||||
.get() as { count?: number } | undefined
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function* iterateSessionUsageRows(db: Database.Database): Iterable<OpenCodeUsageRow> {
|
||||
const projectJoin = getProjectJoin(db)
|
||||
const sessionModelSelect = getSessionModelSelect(db)
|
||||
const fromSql = `FROM session s ${projectJoin}`
|
||||
const whereSql = 's.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0'
|
||||
const rowBytesSql = retainedTextBytesSql([
|
||||
's.id',
|
||||
's.id',
|
||||
's.directory',
|
||||
's.title',
|
||||
'p.worktree',
|
||||
columnExists(db, 'session', 'model') ? 's.model' : "''"
|
||||
])
|
||||
assertNoOversizedRows(db, fromSql, whereSql, rowBytesSql)
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect},
|
||||
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
|
||||
${fromSql}
|
||||
WHERE ${whereSql}
|
||||
AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES}
|
||||
ORDER BY s.time_created, s.id`
|
||||
)
|
||||
.iterate() as Iterable<OpenCodeSessionUsageRow>
|
||||
|
||||
for (const row of rows) {
|
||||
yield {
|
||||
id: row.id,
|
||||
session_id: row.session_id,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
directory: row.directory,
|
||||
title: row.title,
|
||||
worktree: row.worktree,
|
||||
session_model: row.session_model,
|
||||
data: JSON.stringify({
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
reasoning: row.tokens_reasoning,
|
||||
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
|
||||
cache: { read: row.tokens_cache_read, write: 0 }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iterateMessageUsageRows(
|
||||
db: Database.Database,
|
||||
table: 'message' | 'session_message',
|
||||
assistantPredicate: string
|
||||
): Iterable<OpenCodeUsageRow> {
|
||||
const alias = table === 'message' ? 'm' : 'sm'
|
||||
const projectJoin = getProjectJoin(db)
|
||||
const sessionModelSelect = getSessionModelSelect(db)
|
||||
const fromSql = `FROM ${table} ${alias} JOIN session s ON s.id = ${alias}.session_id ${projectJoin}`
|
||||
const rowBytesSql = retainedTextBytesSql([
|
||||
`${alias}.id`,
|
||||
`${alias}.session_id`,
|
||||
`${alias}.data`,
|
||||
's.directory',
|
||||
's.title',
|
||||
'p.worktree',
|
||||
columnExists(db, 'session', 'model') ? 's.model' : "''"
|
||||
])
|
||||
assertNoOversizedRows(db, fromSql, assistantPredicate, rowBytesSql)
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${alias}.id, ${alias}.session_id, ${alias}.time_created,
|
||||
${alias}.time_updated, ${alias}.data,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect}
|
||||
${fromSql}
|
||||
WHERE ${assistantPredicate}
|
||||
AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES}
|
||||
ORDER BY ${alias}.time_created, ${alias}.id`
|
||||
)
|
||||
.iterate() as Iterable<OpenCodeUsageRow>
|
||||
}
|
||||
|
||||
export function iterateOpenCodeUsageRows(db: Database.Database): Iterable<OpenCodeUsageRow> {
|
||||
if (!tableExists(db, 'session')) {
|
||||
return []
|
||||
}
|
||||
if (getSessionUsageRowCount(db) > 0) {
|
||||
return iterateSessionUsageRows(db)
|
||||
}
|
||||
|
||||
if (getAssistantSessionMessageCount(db) > 0) {
|
||||
const assistantPredicate = columnExists(db, 'session_message', 'type')
|
||||
? "sm.type = 'assistant'"
|
||||
: boundedJsonPredicate('sm.data', "json_extract(sm.data, '$.tokens.input') IS NOT NULL")
|
||||
return iterateMessageUsageRows(db, 'session_message', assistantPredicate)
|
||||
}
|
||||
|
||||
if (!tableExists(db, 'message')) {
|
||||
return []
|
||||
}
|
||||
assertNoOversizedJsonCandidates(db, 'message', '1 = 1')
|
||||
return iterateMessageUsageRows(
|
||||
db,
|
||||
'message',
|
||||
boundedJsonPredicate('m.data', "json_extract(m.data, '$.role') = 'assistant'")
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageBreakdownRow,
|
||||
@@ -13,10 +14,6 @@ import type {
|
||||
OpenCodeUsageSummary
|
||||
} from '../../shared/opencode-usage-types'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
readUsageProjectionStateFile,
|
||||
writeUsageProjectionStateFileWithRecovery
|
||||
} from '../usage-projection-state-file'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner'
|
||||
@@ -164,11 +161,10 @@ export class OpenCodeUsageStore {
|
||||
private load(): OpenCodeUsagePersistedState {
|
||||
try {
|
||||
const usageFile = getOpenCodeUsageFile()
|
||||
const raw = readUsageProjectionStateFile(usageFile)
|
||||
if (raw === null) {
|
||||
if (!existsSync(usageFile)) {
|
||||
return getDefaultState()
|
||||
}
|
||||
const parsed = JSON.parse(raw) as OpenCodeUsagePersistedState
|
||||
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState
|
||||
return normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
...parsed,
|
||||
@@ -185,12 +181,13 @@ export class OpenCodeUsageStore {
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getOpenCodeUsageFile()
|
||||
this.state = writeUsageProjectionStateFileWithRecovery(usageFile, this.state, (error) => {
|
||||
const reset = getDefaultState()
|
||||
reset.scanState.enabled = this.state.scanState.enabled
|
||||
reset.scanState.lastScanError = error.message
|
||||
return reset
|
||||
})
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<OpenCodeUsageScanState> {
|
||||
|
||||
Reference in New Issue
Block a user