mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
refactor(usage): split AI-usage scanners and stores under the max-lines budget (#14668)
The three usage scanners and their stores, plus the renderer usage-overview model, each carried a file-level `eslint-disable max-lines` and had grown to 338-769 counted lines against a 300-line budget. AGENTS.md calls for splitting rather than suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all seven suppressions and prunes their entries (341 -> 334). Each file is cut along the seams it already had -- and that several of the suppression comments named out loud: filesystem discovery / record parsing / attribution / aggregation for the scanners, and pricing policy / scope filters / rollups / session rows / automation attribution for the stores. Pure move, no behavior change. Code is relocated verbatim; the only edits are import plumbing and, where a private class method became a free function, the mechanical `this.state` -> `state` parameter threading. Every converted call site passes `this.state` at call time and the automation path takes a live `getState: () => this.state` getter, so no state is snapshotted. No barrel exports: each new module owns real logic and importers point at the owner. Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green (remaining failures are pre-existing load flakes in untouched files, each green when re-run serially), no import cycles among the 64 affected modules, and a statement-level diff of every split confirms the moves are verbatim.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, isAbsolute, join } from 'node:path'
|
||||
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
|
||||
import type { OpenCodeUsageProcessedDatabase } from './types'
|
||||
|
||||
type OpenCodeDatabaseOverride = {
|
||||
isConfigured: boolean
|
||||
path: string | null
|
||||
}
|
||||
|
||||
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
|
||||
const raw = process.env.OPENCODE_DB?.trim()
|
||||
if (!raw) {
|
||||
return { isConfigured: false, path: null }
|
||||
}
|
||||
if (raw === ':memory:') {
|
||||
return { isConfigured: true, path: null }
|
||||
}
|
||||
return {
|
||||
isConfigured: true,
|
||||
path: isAbsolute(raw) ? raw : join(dataDirectory, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
|
||||
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
|
||||
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
|
||||
export async function listOpenCodeDatabases(
|
||||
/** Lets a caller report the refusal; an empty list otherwise reads as
|
||||
* "OpenCode not used" rather than "we could not look". */
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): Promise<string[]> {
|
||||
const dataDirectory = resolveOpenCodeDataDirectory()
|
||||
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
|
||||
if (databaseOverride.isConfigured) {
|
||||
if (!databaseOverride.path) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
|
||||
? [databaseOverride.path]
|
||||
: []
|
||||
} catch (error) {
|
||||
reportRefusal(databaseOverride.path, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await wslGatedReaddir(dataDirectory, 'scan')
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDirectory, entry.name))
|
||||
.sort()
|
||||
} catch (error) {
|
||||
reportRefusal(dataDirectory, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function reportRefusal(
|
||||
path: string,
|
||||
error: unknown,
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): void {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
onRefusal?.(path, error)
|
||||
}
|
||||
}
|
||||
|
||||
export function compareOpenCodeClaimPriority(left: string, right: string): number {
|
||||
// Why: the canonical opencode.db is the live database; it must claim
|
||||
// duplicated sessions ahead of stale sibling copies. Remaining ties use
|
||||
// path order so ownership is deterministic across rescans.
|
||||
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank
|
||||
}
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export async function getProcessedDatabaseInfo(
|
||||
dbPath: string
|
||||
): Promise<OpenCodeUsageProcessedDatabase> {
|
||||
const dbStat = await stat(dbPath)
|
||||
return {
|
||||
path: dbPath,
|
||||
mtimeMs: dbStat.mtimeMs,
|
||||
size: dbStat.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
|
||||
import type { OpenCodeUsageRow } from './opencode-usage-row-queries'
|
||||
import type { OpenCodeUsageParsedEvent } from './types'
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractModelLabel(data: Record<string, unknown>, sessionModel: unknown): string | null {
|
||||
const directModel = extractString(data.modelID) ?? extractString(data.modelId)
|
||||
const directProvider = extractString(data.providerID) ?? extractString(data.providerId)
|
||||
if (directModel) {
|
||||
return directProvider ? `${directProvider}/${directModel}` : directModel
|
||||
}
|
||||
|
||||
const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel)
|
||||
if (!modelObject) {
|
||||
return null
|
||||
}
|
||||
const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id)
|
||||
const providerID = extractString(modelObject.providerID)
|
||||
if (!modelID) {
|
||||
return null
|
||||
}
|
||||
return providerID ? `${providerID}/${modelID}` : modelID
|
||||
}
|
||||
|
||||
function extractCwd(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
|
||||
const pathData = parseJsonObject(data.path)
|
||||
return (
|
||||
extractString(pathData?.cwd) ??
|
||||
extractString(row.directory) ??
|
||||
extractString(row.worktree) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeMillis(value: unknown): number | null {
|
||||
const numeric = ensureNumber(value)
|
||||
if (numeric <= 0) {
|
||||
return null
|
||||
}
|
||||
return numeric < 10_000_000_000 ? numeric * 1000 : numeric
|
||||
}
|
||||
|
||||
function extractTimestamp(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
|
||||
const timeData = parseJsonObject(data.time)
|
||||
const millis =
|
||||
normalizeMillis(timeData?.completed) ??
|
||||
normalizeMillis(timeData?.created) ??
|
||||
normalizeMillis(row.time_updated) ??
|
||||
normalizeMillis(row.time_created)
|
||||
return millis ? new Date(millis).toISOString() : null
|
||||
}
|
||||
|
||||
export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null {
|
||||
const data = parseJsonObject(row.data)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = parseJsonObject(data.tokens)
|
||||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
const cache = parseJsonObject(tokens.cache)
|
||||
const inputTokens = ensureNumber(tokens.input)
|
||||
const outputTokens = ensureNumber(tokens.output)
|
||||
const reasoningOutputTokens = ensureNumber(tokens.reasoning)
|
||||
const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens)
|
||||
const totalTokens =
|
||||
ensureNumber(tokens.total) > 0
|
||||
? ensureNumber(tokens.total)
|
||||
: inputTokens + outputTokens + reasoningOutputTokens
|
||||
|
||||
if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timestamp = extractTimestamp(data, row)
|
||||
if (!timestamp) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
timestamp,
|
||||
cwd: extractCwd(data, row),
|
||||
model: extractModelLabel(data, row.session_model),
|
||||
estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
|
||||
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 = {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
export 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[]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import {
|
||||
looksLikeWindowsPath,
|
||||
normalizeComparablePath,
|
||||
normalizeFsPath
|
||||
} from '../usage/usage-path-comparison'
|
||||
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
|
||||
import type { OpenCodeUsageAttributedEvent, OpenCodeUsageParsedEvent } from './types'
|
||||
|
||||
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
|
||||
|
||||
function getDefaultProjectLabel(cwd: string | null): string {
|
||||
if (!cwd) {
|
||||
return 'Unknown location'
|
||||
}
|
||||
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(-2).join('/')
|
||||
}
|
||||
return parts.at(-1) ?? cwd
|
||||
}
|
||||
|
||||
function localDayFromTimestamp(timestamp: string): string | null {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
const year = parsed.getFullYear()
|
||||
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(parsed.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function isContainingPath(candidatePath: string, targetPath: string): boolean {
|
||||
const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath)
|
||||
const relativePath = useWin32
|
||||
? win32.relative(candidatePath, targetPath)
|
||||
: posix.relative(candidatePath, targetPath)
|
||||
if (!relativePath) {
|
||||
return true
|
||||
}
|
||||
const isAbsoluteRelative = useWin32
|
||||
? win32.isAbsolute(relativePath)
|
||||
: posix.isAbsolute(relativePath)
|
||||
const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}`
|
||||
// Why: `..name` is a valid child path; only `..` and `../...` escape.
|
||||
return (
|
||||
!isAbsoluteRelative &&
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(parentPrefix) &&
|
||||
relativePath !== '.'
|
||||
)
|
||||
}
|
||||
|
||||
export async function buildWorktreesWithCanonicalPaths(
|
||||
worktrees: OpenCodeUsageWorktreeRef[]
|
||||
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
|
||||
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
||||
}
|
||||
|
||||
async function canonicalizePath(pathValue: string): Promise<string> {
|
||||
try {
|
||||
return normalizeFsPath(await realpath(pathValue))
|
||||
} catch {
|
||||
return normalizeFsPath(pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
function findContainingWorktree(
|
||||
cwd: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): OpenCodeUsageWorktreeRef | null {
|
||||
const normalizedCwd = normalizeFsPath(cwd)
|
||||
for (const worktree of worktrees) {
|
||||
if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) {
|
||||
return worktree
|
||||
}
|
||||
if (isContainingPath(worktree.canonicalPath, normalizedCwd)) {
|
||||
return worktree
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function attributeOpenCodeUsageEvent(
|
||||
event: OpenCodeUsageParsedEvent,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): Promise<OpenCodeUsageAttributedEvent | null> {
|
||||
const day = localDayFromTimestamp(event.timestamp)
|
||||
if (!day) {
|
||||
return null
|
||||
}
|
||||
|
||||
let repoId: string | null = null
|
||||
let worktreeId: string | null = null
|
||||
let projectKey = 'unscoped'
|
||||
let projectLabel = getDefaultProjectLabel(event.cwd)
|
||||
|
||||
if (event.cwd) {
|
||||
const worktree = findContainingWorktree(event.cwd, worktrees)
|
||||
if (worktree) {
|
||||
repoId = worktree.repoId
|
||||
worktreeId = worktree.worktreeId
|
||||
projectKey = `worktree:${worktree.worktreeId}`
|
||||
projectLabel = worktree.displayName
|
||||
} else {
|
||||
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
day,
|
||||
projectKey,
|
||||
projectLabel,
|
||||
repoId,
|
||||
worktreeId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
|
||||
import { OPENCODE_USAGE_SCHEMA_VERSION } from './opencode-usage-provider'
|
||||
|
||||
const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION
|
||||
|
||||
export function getDefaultState(): OpenCodeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
worktreeFingerprint: null,
|
||||
processedDatabases: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePersistedState(
|
||||
state: OpenCodeUsagePersistedState
|
||||
): OpenCodeUsagePersistedState {
|
||||
if (state.schemaVersion !== SCHEMA_VERSION) {
|
||||
return getDefaultState()
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
|
||||
...database,
|
||||
sessions: (database.sessions ?? []).map(normalizeSessionCost),
|
||||
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
|
||||
})),
|
||||
sessions: state.sessions.map(normalizeSessionCost),
|
||||
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDailyAggregateCost(
|
||||
entry: OpenCodeUsageDailyAggregate
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
return {
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSessionCost(
|
||||
session: OpenCodeUsagePersistedState['sessions'][number]
|
||||
): OpenCodeUsagePersistedState['sessions'][number] {
|
||||
return {
|
||||
...session,
|
||||
estimatedCostUsd: session.estimatedCostUsd ?? null,
|
||||
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
})),
|
||||
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
})),
|
||||
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
stat: mocks.stat
|
||||
}))
|
||||
|
||||
import { listOpenCodeDatabases } from './scanner'
|
||||
import { listOpenCodeDatabases } from './opencode-database-discovery'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
|
||||
@@ -3,13 +3,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import {
|
||||
attributeOpenCodeUsageEvent,
|
||||
listOpenCodeDatabases,
|
||||
parseOpenCodeUsageDatabase,
|
||||
parseOpenCodeUsageRow,
|
||||
scanOpenCodeUsageDatabases
|
||||
} from './scanner'
|
||||
import { listOpenCodeDatabases } from './opencode-database-discovery'
|
||||
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
|
||||
import { attributeOpenCodeUsageEvent } from './opencode-usage-worktree-attribution'
|
||||
import { parseOpenCodeUsageDatabase, scanOpenCodeUsageDatabases } from './scanner'
|
||||
|
||||
const WORKTREE = '/workspace/repo'
|
||||
|
||||
|
||||
@@ -1,501 +1,27 @@
|
||||
/* 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 { realpath, stat } from 'node:fs/promises'
|
||||
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
|
||||
import {
|
||||
looksLikeWindowsPath,
|
||||
normalizeComparablePath,
|
||||
normalizeFsPath
|
||||
} from '../usage/usage-path-comparison'
|
||||
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
|
||||
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
|
||||
compareOpenCodeClaimPriority,
|
||||
getProcessedDatabaseInfo,
|
||||
listOpenCodeDatabases
|
||||
} from './opencode-database-discovery'
|
||||
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
|
||||
import { selectUsageRows } from './opencode-usage-row-queries'
|
||||
import {
|
||||
attributeOpenCodeUsageEvent,
|
||||
buildWorktreesWithCanonicalPaths,
|
||||
type OpenCodeUsageWorktreeRef
|
||||
} from './opencode-usage-worktree-attribution'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsageParsedEvent,
|
||||
OpenCodeUsagePersistedDatabase,
|
||||
OpenCodeUsageProcessedDatabase,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
|
||||
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
|
||||
|
||||
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
|
||||
|
||||
type OpenCodeDatabaseOverride = {
|
||||
isConfigured: boolean
|
||||
path: string | null
|
||||
}
|
||||
|
||||
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
|
||||
const raw = process.env.OPENCODE_DB?.trim()
|
||||
if (!raw) {
|
||||
return { isConfigured: false, path: null }
|
||||
}
|
||||
if (raw === ':memory:') {
|
||||
return { isConfigured: true, path: null }
|
||||
}
|
||||
return {
|
||||
isConfigured: true,
|
||||
path: isAbsolute(raw) ? raw : join(dataDirectory, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
|
||||
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
|
||||
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
|
||||
export async function listOpenCodeDatabases(
|
||||
/** Lets a caller report the refusal; an empty list otherwise reads as
|
||||
* "OpenCode not used" rather than "we could not look". */
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): Promise<string[]> {
|
||||
const dataDirectory = resolveOpenCodeDataDirectory()
|
||||
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
|
||||
if (databaseOverride.isConfigured) {
|
||||
if (!databaseOverride.path) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
|
||||
? [databaseOverride.path]
|
||||
: []
|
||||
} catch (error) {
|
||||
reportRefusal(databaseOverride.path, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await wslGatedReaddir(dataDirectory, 'scan')
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDirectory, entry.name))
|
||||
.sort()
|
||||
} catch (error) {
|
||||
reportRefusal(dataDirectory, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function reportRefusal(
|
||||
path: string,
|
||||
error: unknown,
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): void {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
onRefusal?.(path, error)
|
||||
}
|
||||
}
|
||||
|
||||
function compareOpenCodeClaimPriority(left: string, right: string): number {
|
||||
// Why: the canonical opencode.db is the live database; it must claim
|
||||
// duplicated sessions ahead of stale sibling copies. Remaining ties use
|
||||
// path order so ownership is deterministic across rescans.
|
||||
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank
|
||||
}
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export async function getProcessedDatabaseInfo(
|
||||
dbPath: string
|
||||
): Promise<OpenCodeUsageProcessedDatabase> {
|
||||
const dbStat = await stat(dbPath)
|
||||
return {
|
||||
path: dbPath,
|
||||
mtimeMs: dbStat.mtimeMs,
|
||||
size: dbStat.size
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractModelLabel(data: Record<string, unknown>, sessionModel: unknown): string | null {
|
||||
const directModel = extractString(data.modelID) ?? extractString(data.modelId)
|
||||
const directProvider = extractString(data.providerID) ?? extractString(data.providerId)
|
||||
if (directModel) {
|
||||
return directProvider ? `${directProvider}/${directModel}` : directModel
|
||||
}
|
||||
|
||||
const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel)
|
||||
if (!modelObject) {
|
||||
return null
|
||||
}
|
||||
const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id)
|
||||
const providerID = extractString(modelObject.providerID)
|
||||
if (!modelID) {
|
||||
return null
|
||||
}
|
||||
return providerID ? `${providerID}/${modelID}` : modelID
|
||||
}
|
||||
|
||||
function extractCwd(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
|
||||
const pathData = parseJsonObject(data.path)
|
||||
return (
|
||||
extractString(pathData?.cwd) ??
|
||||
extractString(row.directory) ??
|
||||
extractString(row.worktree) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeMillis(value: unknown): number | null {
|
||||
const numeric = ensureNumber(value)
|
||||
if (numeric <= 0) {
|
||||
return null
|
||||
}
|
||||
return numeric < 10_000_000_000 ? numeric * 1000 : numeric
|
||||
}
|
||||
|
||||
function extractTimestamp(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
|
||||
const timeData = parseJsonObject(data.time)
|
||||
const millis =
|
||||
normalizeMillis(timeData?.completed) ??
|
||||
normalizeMillis(timeData?.created) ??
|
||||
normalizeMillis(row.time_updated) ??
|
||||
normalizeMillis(row.time_created)
|
||||
return millis ? new Date(millis).toISOString() : null
|
||||
}
|
||||
|
||||
export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null {
|
||||
const data = parseJsonObject(row.data)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = parseJsonObject(data.tokens)
|
||||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
const cache = parseJsonObject(tokens.cache)
|
||||
const inputTokens = ensureNumber(tokens.input)
|
||||
const outputTokens = ensureNumber(tokens.output)
|
||||
const reasoningOutputTokens = ensureNumber(tokens.reasoning)
|
||||
const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens)
|
||||
const totalTokens =
|
||||
ensureNumber(tokens.total) > 0
|
||||
? ensureNumber(tokens.total)
|
||||
: inputTokens + outputTokens + reasoningOutputTokens
|
||||
|
||||
if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timestamp = extractTimestamp(data, row)
|
||||
if (!timestamp) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
timestamp,
|
||||
cwd: extractCwd(data, row),
|
||||
model: extractModelLabel(data, row.session_model),
|
||||
estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultProjectLabel(cwd: string | null): string {
|
||||
if (!cwd) {
|
||||
return 'Unknown location'
|
||||
}
|
||||
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(-2).join('/')
|
||||
}
|
||||
return parts.at(-1) ?? cwd
|
||||
}
|
||||
|
||||
function localDayFromTimestamp(timestamp: string): string | null {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
const year = parsed.getFullYear()
|
||||
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(parsed.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function isContainingPath(candidatePath: string, targetPath: string): boolean {
|
||||
const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath)
|
||||
const relativePath = useWin32
|
||||
? win32.relative(candidatePath, targetPath)
|
||||
: posix.relative(candidatePath, targetPath)
|
||||
if (!relativePath) {
|
||||
return true
|
||||
}
|
||||
const isAbsoluteRelative = useWin32
|
||||
? win32.isAbsolute(relativePath)
|
||||
: posix.isAbsolute(relativePath)
|
||||
const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}`
|
||||
// Why: `..name` is a valid child path; only `..` and `../...` escape.
|
||||
return (
|
||||
!isAbsoluteRelative &&
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(parentPrefix) &&
|
||||
relativePath !== '.'
|
||||
)
|
||||
}
|
||||
|
||||
async function buildWorktreesWithCanonicalPaths(
|
||||
worktrees: OpenCodeUsageWorktreeRef[]
|
||||
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
|
||||
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
||||
}
|
||||
|
||||
async function canonicalizePath(pathValue: string): Promise<string> {
|
||||
try {
|
||||
return normalizeFsPath(await realpath(pathValue))
|
||||
} catch {
|
||||
return normalizeFsPath(pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
function findContainingWorktree(
|
||||
cwd: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): OpenCodeUsageWorktreeRef | null {
|
||||
const normalizedCwd = normalizeFsPath(cwd)
|
||||
for (const worktree of worktrees) {
|
||||
if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) {
|
||||
return worktree
|
||||
}
|
||||
if (isContainingPath(worktree.canonicalPath, normalizedCwd)) {
|
||||
return worktree
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function attributeOpenCodeUsageEvent(
|
||||
event: OpenCodeUsageParsedEvent,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): Promise<OpenCodeUsageAttributedEvent | null> {
|
||||
const day = localDayFromTimestamp(event.timestamp)
|
||||
if (!day) {
|
||||
return null
|
||||
}
|
||||
|
||||
let repoId: string | null = null
|
||||
let worktreeId: string | null = null
|
||||
let projectKey = 'unscoped'
|
||||
let projectLabel = getDefaultProjectLabel(event.cwd)
|
||||
|
||||
if (event.cwd) {
|
||||
const worktree = findContainingWorktree(event.cwd, worktrees)
|
||||
if (worktree) {
|
||||
repoId = worktree.repoId
|
||||
worktreeId = worktree.worktreeId
|
||||
projectKey = `worktree:${worktree.worktreeId}`
|
||||
projectLabel = worktree.displayName
|
||||
} else {
|
||||
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
day,
|
||||
projectKey,
|
||||
projectLabel,
|
||||
repoId,
|
||||
worktreeId
|
||||
}
|
||||
}
|
||||
|
||||
function addCost(left: number | null, right: number | null): number | null {
|
||||
if (left === null && right === null) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../shared/opencode-usage-types'
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
|
||||
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
|
||||
|
||||
export function filterDailyAggregatesByScopeAndRange(
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[],
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageDailyAggregate[] {
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return dailyAggregates.filter((row) => {
|
||||
if (scope === 'orca' && !row.worktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff && row.day < cutoff) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function filterSessionsByScopeAndRange(
|
||||
sessions: OpenCodeUsageSession[],
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageSession[] {
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return sessions.filter((session) => {
|
||||
if (scope === 'orca' && !session.primaryWorktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff) {
|
||||
const day = getLocalUsageDay(session.lastTimestamp)
|
||||
if (!day || day < cutoff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageBreakdownRow,
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSessionRow,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../shared/opencode-usage-types'
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
|
||||
|
||||
function addCost(left: number | null, right: number | null): number | null {
|
||||
if (left === null && right === null) {
|
||||
return null
|
||||
}
|
||||
return (left ?? 0) + (right ?? 0)
|
||||
}
|
||||
|
||||
export function buildOpenCodeUsageSummary(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange,
|
||||
filteredDaily: OpenCodeUsageDailyAggregate[],
|
||||
filteredSessions: OpenCodeUsageSession[]
|
||||
): OpenCodeUsageSummary {
|
||||
let inputTokens = 0
|
||||
let cachedInputTokens = 0
|
||||
let outputTokens = 0
|
||||
let reasoningOutputTokens = 0
|
||||
let totalTokens = 0
|
||||
let events = 0
|
||||
let estimatedCostUsd: number | null = null
|
||||
const byModel = new Map<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
for (const row of filteredDaily) {
|
||||
inputTokens += row.inputTokens
|
||||
cachedInputTokens += row.cachedInputTokens
|
||||
outputTokens += row.outputTokens
|
||||
reasoningOutputTokens += row.reasoningOutputTokens
|
||||
totalTokens += row.totalTokens
|
||||
events += row.eventCount
|
||||
estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd)
|
||||
byModel.set(
|
||||
row.model ?? 'Unknown model',
|
||||
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
|
||||
)
|
||||
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
|
||||
}
|
||||
|
||||
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||||
const topProject =
|
||||
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||||
|
||||
return {
|
||||
scope,
|
||||
range,
|
||||
sessions: filteredSessions.length,
|
||||
events,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd,
|
||||
topModel,
|
||||
topProject,
|
||||
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOpenCodeUsageDailyPoints(
|
||||
filteredDaily: OpenCodeUsageDailyAggregate[]
|
||||
): OpenCodeUsageDailyPoint[] {
|
||||
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
|
||||
for (const row of filteredDaily) {
|
||||
const existing = byDay.get(row.day) ?? {
|
||||
day: row.day,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0
|
||||
}
|
||||
existing.inputTokens += row.inputTokens
|
||||
existing.cachedInputTokens += row.cachedInputTokens
|
||||
existing.outputTokens += row.outputTokens
|
||||
existing.reasoningOutputTokens += row.reasoningOutputTokens
|
||||
existing.totalTokens += row.totalTokens
|
||||
byDay.set(row.day, existing)
|
||||
}
|
||||
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
|
||||
}
|
||||
|
||||
export function buildOpenCodeUsageBreakdownRows(
|
||||
kind: OpenCodeUsageBreakdownKind,
|
||||
filteredDaily: OpenCodeUsageDailyAggregate[],
|
||||
filteredSessions: OpenCodeUsageSession[]
|
||||
): OpenCodeUsageBreakdownRow[] {
|
||||
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
|
||||
|
||||
for (const daily of filteredDaily) {
|
||||
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
|
||||
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
|
||||
const existing = rows.get(key) ?? {
|
||||
key,
|
||||
label,
|
||||
sessions: 0,
|
||||
events: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
existing.events += daily.eventCount
|
||||
existing.inputTokens += daily.inputTokens
|
||||
existing.cachedInputTokens += daily.cachedInputTokens
|
||||
existing.outputTokens += daily.outputTokens
|
||||
existing.reasoningOutputTokens += daily.reasoningOutputTokens
|
||||
existing.totalTokens += daily.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd)
|
||||
rows.set(key, existing)
|
||||
}
|
||||
|
||||
if (kind === 'model') {
|
||||
for (const session of filteredSessions) {
|
||||
for (const entry of session.modelBreakdown) {
|
||||
const row = rows.get(entry.modelKey)
|
||||
if (row) {
|
||||
row.sessions++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const session of filteredSessions) {
|
||||
for (const entry of session.locationBreakdown) {
|
||||
const row = rows.get(entry.locationKey)
|
||||
if (row) {
|
||||
row.sessions++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
}
|
||||
|
||||
export function buildOpenCodeUsageRecentSessions(
|
||||
filteredSessions: OpenCodeUsageSession[],
|
||||
limit = 10
|
||||
): OpenCodeUsageSessionRow[] {
|
||||
return filteredSessions.slice(0, limit).map(
|
||||
(session): OpenCodeUsageSessionRow => ({
|
||||
sessionId: session.sessionId,
|
||||
lastActiveAt: session.lastTimestamp,
|
||||
durationMinutes: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
|
||||
60_000
|
||||
)
|
||||
),
|
||||
projectLabel: session.primaryProjectLabel,
|
||||
model: session.primaryModel,
|
||||
events: session.eventCount,
|
||||
inputTokens: session.totalInputTokens,
|
||||
cachedInputTokens: session.totalCachedInputTokens,
|
||||
outputTokens: session.totalOutputTokens,
|
||||
reasoningOutputTokens: session.totalReasoningOutputTokens,
|
||||
totalTokens: session.totalTokens
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,8 @@ vi.mock('./scanner', () => ({
|
||||
scanOpenCodeUsageDatabases: vi.fn()
|
||||
}))
|
||||
|
||||
import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store'
|
||||
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './store'
|
||||
import { normalizePersistedState } from './persisted-state-normalization'
|
||||
import { scanOpenCodeUsageDatabases } from './scanner'
|
||||
|
||||
function createEmptyScanResult() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
@@ -12,49 +11,27 @@ import type {
|
||||
OpenCodeUsageSummary
|
||||
} from '../../shared/opencode-usage-types'
|
||||
import type { Store } from '../persistence'
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
|
||||
import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider'
|
||||
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
|
||||
import type {
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsagePersistedState,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
import { openCodeUsageProvider } from './opencode-usage-provider'
|
||||
import { getDefaultState, normalizePersistedState } from './persisted-state-normalization'
|
||||
import {
|
||||
filterDailyAggregatesByScopeAndRange,
|
||||
filterSessionsByScopeAndRange
|
||||
} from './scope-range-filter'
|
||||
import {
|
||||
buildOpenCodeUsageBreakdownRows,
|
||||
buildOpenCodeUsageDailyPoints,
|
||||
buildOpenCodeUsageRecentSessions,
|
||||
buildOpenCodeUsageSummary
|
||||
} from './snapshot-rollups'
|
||||
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
|
||||
|
||||
const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION
|
||||
|
||||
let _openCodeUsageFile: string | null = null
|
||||
|
||||
function getDefaultState(): OpenCodeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
worktreeFingerprint: null,
|
||||
processedDatabases: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePersistedState(
|
||||
state: OpenCodeUsagePersistedState
|
||||
): OpenCodeUsagePersistedState {
|
||||
if (state.schemaVersion !== SCHEMA_VERSION) {
|
||||
return getDefaultState()
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
|
||||
...database,
|
||||
sessions: (database.sessions ?? []).map(normalizeSessionCost),
|
||||
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
|
||||
})),
|
||||
sessions: state.sessions.map(normalizeSessionCost),
|
||||
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
|
||||
}
|
||||
}
|
||||
|
||||
export function initOpenCodeUsagePath(): void {
|
||||
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
|
||||
}
|
||||
@@ -66,43 +43,6 @@ function getOpenCodeUsageFile(): string {
|
||||
return _openCodeUsageFile
|
||||
}
|
||||
|
||||
function addCost(left: number | null, right: number | null): number | null {
|
||||
if (left === null && right === null) {
|
||||
return null
|
||||
}
|
||||
return (left ?? 0) + (right ?? 0)
|
||||
}
|
||||
|
||||
function normalizeDailyAggregateCost(
|
||||
entry: OpenCodeUsageDailyAggregate
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
return {
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSessionCost(
|
||||
session: OpenCodeUsagePersistedState['sessions'][number]
|
||||
): OpenCodeUsagePersistedState['sessions'][number] {
|
||||
return {
|
||||
...session,
|
||||
estimatedCostUsd: session.estimatedCostUsd ?? null,
|
||||
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
})),
|
||||
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
})),
|
||||
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
|
||||
...entry,
|
||||
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedDatabases',
|
||||
OpenCodeUsagePersistedState,
|
||||
@@ -145,54 +85,12 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
}
|
||||
|
||||
private buildSummary(scope: OpenCodeUsageScope, range: OpenCodeUsageRange): OpenCodeUsageSummary {
|
||||
const filteredDaily = this.getFilteredDaily(scope, range)
|
||||
const filteredSessions = this.getFilteredSessions(scope, range)
|
||||
|
||||
let inputTokens = 0
|
||||
let cachedInputTokens = 0
|
||||
let outputTokens = 0
|
||||
let reasoningOutputTokens = 0
|
||||
let totalTokens = 0
|
||||
let events = 0
|
||||
let estimatedCostUsd: number | null = null
|
||||
const byModel = new Map<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
for (const row of filteredDaily) {
|
||||
inputTokens += row.inputTokens
|
||||
cachedInputTokens += row.cachedInputTokens
|
||||
outputTokens += row.outputTokens
|
||||
reasoningOutputTokens += row.reasoningOutputTokens
|
||||
totalTokens += row.totalTokens
|
||||
events += row.eventCount
|
||||
estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd)
|
||||
byModel.set(
|
||||
row.model ?? 'Unknown model',
|
||||
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
|
||||
)
|
||||
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
|
||||
}
|
||||
|
||||
const topModel =
|
||||
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||||
const topProject =
|
||||
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||||
|
||||
return {
|
||||
return buildOpenCodeUsageSummary(
|
||||
scope,
|
||||
range,
|
||||
sessions: filteredSessions.length,
|
||||
events,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd,
|
||||
topModel,
|
||||
topProject,
|
||||
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
|
||||
}
|
||||
this.getFilteredDaily(scope, range),
|
||||
this.getFilteredSessions(scope, range)
|
||||
)
|
||||
}
|
||||
|
||||
async getDaily(
|
||||
@@ -207,24 +105,7 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageDailyPoint[] {
|
||||
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
|
||||
for (const row of this.getFilteredDaily(scope, range)) {
|
||||
const existing = byDay.get(row.day) ?? {
|
||||
day: row.day,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0
|
||||
}
|
||||
existing.inputTokens += row.inputTokens
|
||||
existing.cachedInputTokens += row.cachedInputTokens
|
||||
existing.outputTokens += row.outputTokens
|
||||
existing.reasoningOutputTokens += row.reasoningOutputTokens
|
||||
existing.totalTokens += row.totalTokens
|
||||
byDay.set(row.day, existing)
|
||||
}
|
||||
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
|
||||
return buildOpenCodeUsageDailyPoints(this.getFilteredDaily(scope, range))
|
||||
}
|
||||
|
||||
async getBreakdown(
|
||||
@@ -241,56 +122,11 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
range: OpenCodeUsageRange,
|
||||
kind: OpenCodeUsageBreakdownKind
|
||||
): OpenCodeUsageBreakdownRow[] {
|
||||
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
|
||||
const filteredDaily = this.getFilteredDaily(scope, range)
|
||||
const filteredSessions = this.getFilteredSessions(scope, range)
|
||||
|
||||
for (const daily of filteredDaily) {
|
||||
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
|
||||
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
|
||||
const existing = rows.get(key) ?? {
|
||||
key,
|
||||
label,
|
||||
sessions: 0,
|
||||
events: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
existing.events += daily.eventCount
|
||||
existing.inputTokens += daily.inputTokens
|
||||
existing.cachedInputTokens += daily.cachedInputTokens
|
||||
existing.outputTokens += daily.outputTokens
|
||||
existing.reasoningOutputTokens += daily.reasoningOutputTokens
|
||||
existing.totalTokens += daily.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd)
|
||||
rows.set(key, existing)
|
||||
}
|
||||
|
||||
if (kind === 'model') {
|
||||
for (const session of filteredSessions) {
|
||||
for (const entry of session.modelBreakdown) {
|
||||
const row = rows.get(entry.modelKey)
|
||||
if (row) {
|
||||
row.sessions++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const session of filteredSessions) {
|
||||
for (const entry of session.locationBreakdown) {
|
||||
const row = rows.get(entry.locationKey)
|
||||
if (row) {
|
||||
row.sessions++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
return buildOpenCodeUsageBreakdownRows(
|
||||
kind,
|
||||
this.getFilteredDaily(scope, range),
|
||||
this.getFilteredSessions(scope, range)
|
||||
)
|
||||
}
|
||||
|
||||
async getRecentSessions(
|
||||
@@ -307,61 +143,20 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
range: OpenCodeUsageRange,
|
||||
limit = 10
|
||||
): OpenCodeUsageSessionRow[] {
|
||||
return this.getFilteredSessions(scope, range)
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(session): OpenCodeUsageSessionRow => ({
|
||||
sessionId: session.sessionId,
|
||||
lastActiveAt: session.lastTimestamp,
|
||||
durationMinutes: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(session.lastTimestamp).getTime() -
|
||||
new Date(session.firstTimestamp).getTime()) /
|
||||
60_000
|
||||
)
|
||||
),
|
||||
projectLabel: session.primaryProjectLabel,
|
||||
model: session.primaryModel,
|
||||
events: session.eventCount,
|
||||
inputTokens: session.totalInputTokens,
|
||||
cachedInputTokens: session.totalCachedInputTokens,
|
||||
outputTokens: session.totalOutputTokens,
|
||||
reasoningOutputTokens: session.totalReasoningOutputTokens,
|
||||
totalTokens: session.totalTokens
|
||||
})
|
||||
)
|
||||
return buildOpenCodeUsageRecentSessions(this.getFilteredSessions(scope, range), limit)
|
||||
}
|
||||
|
||||
private getFilteredDaily(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageDailyAggregate[] {
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((row) => {
|
||||
if (scope === 'orca' && !row.worktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff && row.day < cutoff) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return filterDailyAggregatesByScopeAndRange(this.state.dailyAggregates, scope, range)
|
||||
}
|
||||
|
||||
private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) {
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.sessions.filter((session) => {
|
||||
if (scope === 'orca' && !session.primaryWorktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff) {
|
||||
const day = getLocalUsageDay(session.lastTimestamp)
|
||||
if (!day || day < cutoff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
private getFilteredSessions(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageSession[] {
|
||||
return filterSessionsByScopeAndRange(this.state.sessions, scope, range)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user