Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-23 18:35:31 -07:00
committed by GitHub
co-authored by Orca
parent 6eb70d8370
commit aab112933e
1577 changed files with 15166 additions and 91654 deletions
+173 -93
View File
@@ -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'")
)
}
+11 -14
View File
@@ -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> {