mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
perf(usage): resolve each cwd's worktree once per scan (#21130)
* perf(usage): resolve each cwd's worktree once per scan Codex and OpenCode attribution ran the worktree containment search for every parsed event, so a cold scan cost events x worktrees. On 745 MB of real rollouts (~20k events) that is 1.2s with 0 worktrees, 5.0s with 100, 12.8s with 300 and 39.8s with 1000; a full corpus with hundreds of remembered worktrees is where the STA-7724 reparse burned minutes of main-thread CPU. A scan holds only a few hundred distinct cwds, so both scanners now build one memoized resolver per scan and thread it through parsing instead of passing the worktree list to every event. * refactor(usage): make the worktree resolver own canonicalization `createUsageWorktreeResolver` now takes raw worktree refs and canonicalizes them itself, so each scanner has one entry point and neither keeps a private `buildWorktreesWithCanonicalPaths` or `canonicalizePath`. The resolver unit test counts comparisons through the same `areWorktreePathsEqual` mock the scanner-level test uses instead of a property getter.
This commit is contained in:
@@ -1,11 +1,6 @@
|
||||
import { win32, posix } from 'node:path'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import {
|
||||
looksLikeWindowsPath,
|
||||
normalizeComparablePath,
|
||||
normalizeFsPath
|
||||
} from '../usage/usage-path-comparison'
|
||||
import { normalizeComparablePath } from '../usage/usage-path-comparison'
|
||||
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
|
||||
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
|
||||
import type { CodexUsageAttributedEvent, CodexUsageParsedEvent } from './types'
|
||||
|
||||
export type CodexUsageWorktreeRef = UsageScanWorktreeRef
|
||||
@@ -32,50 +27,9 @@ function localDayFromTimestamp(timestamp: string): string | null {
|
||||
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
|
||||
}
|
||||
// Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an
|
||||
// absolute `D:\\other` path instead of a `..`-prefixed relative. Treating
|
||||
// that as "contained" would attribute off-drive Codex usage to the wrong
|
||||
// Orca worktree.
|
||||
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 !== '.'
|
||||
)
|
||||
}
|
||||
|
||||
function findContainingWorktree(
|
||||
cwd: string,
|
||||
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): CodexUsageWorktreeRef | 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 attributeCodexUsageEvent(
|
||||
event: CodexUsageParsedEvent,
|
||||
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
|
||||
resolveWorktree: UsageWorktreeResolver
|
||||
): Promise<CodexUsageAttributedEvent | null> {
|
||||
const day = localDayFromTimestamp(event.timestamp)
|
||||
if (!day) {
|
||||
@@ -88,7 +42,7 @@ export async function attributeCodexUsageEvent(
|
||||
let projectLabel = getDefaultProjectLabel(event.cwd)
|
||||
|
||||
if (event.cwd) {
|
||||
const worktree = findContainingWorktree(event.cwd, worktrees)
|
||||
const worktree = resolveWorktree(event.cwd)
|
||||
if (worktree) {
|
||||
repoId = worktree.repoId
|
||||
worktreeId = worktree.worktreeId
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeLogic from '../ipc/worktree-logic'
|
||||
|
||||
const { getPathMock, homedirMock, worktreePathComparisons } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
worktreePathComparisons: { count: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof NodeOs>('node:os')
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorktreeLogic>()
|
||||
return {
|
||||
...actual,
|
||||
areWorktreePathsEqual: (left: string, right: string) => {
|
||||
worktreePathComparisons.count += 1
|
||||
return actual.areWorktreePathsEqual(left, right)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { scanCodexUsageFiles } from './scanner'
|
||||
|
||||
const WORKTREE_COUNT = 4
|
||||
const EVENTS_PER_FILE = 4
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
|
||||
function usageRecord(timestamp: string, totalInputTokens: number): string {
|
||||
return `${JSON.stringify({
|
||||
timestamp,
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
model: 'gpt-5-codex',
|
||||
last_token_usage: {
|
||||
input_tokens: 1,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1
|
||||
},
|
||||
total_token_usage: {
|
||||
input_tokens: totalInputTokens,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: totalInputTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function writeSessionFile(
|
||||
sessionsDir: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
tokenOffset: number
|
||||
): void {
|
||||
// Why: event keys are content-derived, so identical records across files would be
|
||||
// deduped by cross-file ownership and the scan would see one session, not three.
|
||||
const records = [
|
||||
`${JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd } })}\n`,
|
||||
...Array.from({ length: EVENTS_PER_FILE }, (_, index) =>
|
||||
usageRecord(
|
||||
`2026-07-21T12:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
tokenOffset + index + 1
|
||||
)
|
||||
)
|
||||
]
|
||||
writeFileSync(join(sessionsDir, `${sessionId}.jsonl`), records.join(''), 'utf-8')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CODEX_HOME
|
||||
worktreePathComparisons.count = 0
|
||||
// Why: worktree canonicalization realpaths, so /var vs /private/var would never match.
|
||||
fakeHomeDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-codex-memo-home-')))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-memo-user-data-'))
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(fakeHomeDir)
|
||||
getPathMock.mockImplementation((name: string) => {
|
||||
if (name === 'userData') {
|
||||
return userDataDir
|
||||
}
|
||||
throw new Error(`unexpected app.getPath(${name})`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
if (originalCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
} else {
|
||||
process.env.CODEX_HOME = originalCodexHome
|
||||
}
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resolves each distinct cwd once per scan, not once per event', async () => {
|
||||
const sessionsDir = join(fakeHomeDir, '.codex', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const matchedCwd = join(fakeHomeDir, 'worktrees', 'repo-003', 'packages', 'app')
|
||||
const unmatchedCwd = join(fakeHomeDir, 'elsewhere', 'project')
|
||||
// Two files share a cwd so the memo must survive across files, not just within one.
|
||||
writeSessionFile(sessionsDir, 'session-a', matchedCwd, 0)
|
||||
writeSessionFile(sessionsDir, 'session-b', matchedCwd, 1_000)
|
||||
writeSessionFile(sessionsDir, 'session-c', unmatchedCwd, 2_000)
|
||||
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => {
|
||||
const worktreePath = join(fakeHomeDir, 'worktrees', `repo-${String(index).padStart(3, '0')}`)
|
||||
mkdirSync(worktreePath, { recursive: true })
|
||||
return {
|
||||
repoId: `repo-${index}`,
|
||||
worktreeId: `repo-${index}::${worktreePath}`,
|
||||
path: worktreePath,
|
||||
displayName: `Repo ${index}`
|
||||
}
|
||||
})
|
||||
|
||||
const result = await scanCodexUsageFiles(worktrees, [])
|
||||
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
const attributedWorktreeIds = new Set(
|
||||
result.sessions.flatMap((session) =>
|
||||
session.locationBreakdown.map((location) => location.worktreeId)
|
||||
)
|
||||
)
|
||||
expect(attributedWorktreeIds).toEqual(
|
||||
new Set([`repo-3::${join(fakeHomeDir, 'worktrees', 'repo-003')}`, null])
|
||||
)
|
||||
// Two distinct cwds against every worktree; an unmemoized scan would pay this per event.
|
||||
expect(worktreePathComparisons.count).toBeLessThanOrEqual(2 * WORKTREE_COUNT)
|
||||
expect(worktreePathComparisons.count).toBeLessThan(EVENTS_PER_FILE * WORKTREE_COUNT)
|
||||
})
|
||||
@@ -10,6 +10,7 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
|
||||
import { attributeCodexUsageEvent } from './codex-usage-event-attribution'
|
||||
import { parseCodexUsageRecord } from './codex-usage-record-parser'
|
||||
|
||||
@@ -208,22 +209,20 @@ describe('attributeCodexUsageEvent', () => {
|
||||
reasoningOutputTokens: 10,
|
||||
totalTokens: 125
|
||||
},
|
||||
[
|
||||
await createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo/app',
|
||||
path: '/workspace/repo/app',
|
||||
displayName: 'App',
|
||||
canonicalPath: '/workspace/repo/app'
|
||||
displayName: 'App'
|
||||
},
|
||||
{
|
||||
repoId: 'repo-2',
|
||||
worktreeId: 'repo-2::/workspace/repo/app2',
|
||||
path: '/workspace/repo/app2',
|
||||
displayName: 'App 2',
|
||||
canonicalPath: '/workspace/repo/app2'
|
||||
displayName: 'App 2'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('worktree:repo-2::/workspace/repo/app2')
|
||||
@@ -246,15 +245,14 @@ describe('attributeCodexUsageEvent', () => {
|
||||
reasoningOutputTokens: 10,
|
||||
totalTokens: 125
|
||||
},
|
||||
[
|
||||
await createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Repo',
|
||||
canonicalPath: '/workspace/repo'
|
||||
displayName: 'Repo'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo')
|
||||
@@ -277,15 +275,14 @@ describe('attributeCodexUsageEvent', () => {
|
||||
reasoningOutputTokens: 10,
|
||||
totalTokens: 125
|
||||
},
|
||||
[
|
||||
await createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Repo',
|
||||
canonicalPath: '/workspace/repo'
|
||||
displayName: 'Repo'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session')
|
||||
@@ -307,15 +304,14 @@ describe('attributeCodexUsageEvent', () => {
|
||||
reasoningOutputTokens: 10,
|
||||
totalTokens: 125
|
||||
},
|
||||
[
|
||||
await createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::C:\\repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'Repo',
|
||||
canonicalPath: 'C:\\repo'
|
||||
displayName: 'Repo'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('cwd:d:/other/repo')
|
||||
|
||||
@@ -2,10 +2,12 @@ import { basename } from 'node:path'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
|
||||
import {
|
||||
canonicalizePath,
|
||||
createUsageWorktreeResolver,
|
||||
type UsageWorktreeResolver
|
||||
} from '../usage/usage-worktree-resolver'
|
||||
import {
|
||||
getLegacySourceSkipBytesByPath,
|
||||
listCodexSessionFiles,
|
||||
yieldToEventLoop
|
||||
@@ -34,12 +36,6 @@ export async function getProcessedFileInfo(filePath: string): Promise<CodexUsage
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWorktreesWithCanonicalPaths(
|
||||
worktrees: CodexUsageWorktreeRef[]
|
||||
): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> {
|
||||
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
||||
}
|
||||
|
||||
type CodexUsageMetric = { hasInferredPricing: boolean }
|
||||
|
||||
const codexUsageAggregation = createUsageEventAggregation<
|
||||
@@ -66,7 +62,7 @@ const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregat
|
||||
|
||||
export async function parseCodexUsageFile(
|
||||
filePath: string,
|
||||
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[],
|
||||
resolveWorktree: UsageWorktreeResolver,
|
||||
options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {}
|
||||
): Promise<CodexUsagePersistedFile> {
|
||||
const processedFile = await getProcessedFileInfo(filePath)
|
||||
@@ -104,7 +100,7 @@ export async function parseCodexUsageFile(
|
||||
continue
|
||||
}
|
||||
ownedEventKeys.add(parsed.eventKey)
|
||||
const attributed = await attributeCodexUsageEvent(parsed, worktrees)
|
||||
const attributed = await attributeCodexUsageEvent(parsed, resolveWorktree)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
}
|
||||
@@ -128,7 +124,8 @@ export async function scanCodexUsageFiles(
|
||||
}> {
|
||||
const files = await listCodexSessionFiles()
|
||||
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
// Why: one resolver for the whole scan so every file shares the per-cwd memo.
|
||||
const resolveWorktree = await createUsageWorktreeResolver(worktrees)
|
||||
const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files)
|
||||
|
||||
const currentPaths = new Set(files)
|
||||
@@ -187,7 +184,7 @@ export async function scanCodexUsageFiles(
|
||||
|
||||
const parsedByPath = new Map<string, CodexUsagePersistedFile>()
|
||||
for (const [index, filePath] of pathsToParse.entries()) {
|
||||
const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
|
||||
const processed = await parseCodexUsageFile(filePath, resolveWorktree, {
|
||||
skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0,
|
||||
claimEventKey: (eventKey) => {
|
||||
const owner = eventOwnerByKey.get(eventKey)
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
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 { normalizeComparablePath } from '../usage/usage-path-comparison'
|
||||
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
|
||||
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
|
||||
import type { OpenCodeUsageAttributedEvent, OpenCodeUsageParsedEvent } from './types'
|
||||
|
||||
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
|
||||
@@ -34,60 +27,9 @@ function localDayFromTimestamp(timestamp: string): string | null {
|
||||
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 })[]
|
||||
resolveWorktree: UsageWorktreeResolver
|
||||
): Promise<OpenCodeUsageAttributedEvent | null> {
|
||||
const day = localDayFromTimestamp(event.timestamp)
|
||||
if (!day) {
|
||||
@@ -100,7 +42,7 @@ export async function attributeOpenCodeUsageEvent(
|
||||
let projectLabel = getDefaultProjectLabel(event.cwd)
|
||||
|
||||
if (event.cwd) {
|
||||
const worktree = findContainingWorktree(event.cwd, worktrees)
|
||||
const worktree = resolveWorktree(event.cwd)
|
||||
if (worktree) {
|
||||
repoId = worktree.repoId
|
||||
worktreeId = worktree.worktreeId
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { listOpenCodeDatabases } from './opencode-database-discovery'
|
||||
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
|
||||
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
|
||||
import { attributeOpenCodeUsageEvent } from './opencode-usage-worktree-attribution'
|
||||
import { parseOpenCodeUsageDatabase, scanOpenCodeUsageDatabases } from './scanner'
|
||||
|
||||
@@ -19,16 +20,15 @@ function createTempDb(): { db: Database.Database; path: string } {
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function worktrees() {
|
||||
return [
|
||||
async function resolveWorktree() {
|
||||
return createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
path: WORKTREE,
|
||||
displayName: 'Repo',
|
||||
canonicalPath: WORKTREE
|
||||
displayName: 'Repo'
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function createSessionTotalsSchema(db: Database.Database): void {
|
||||
@@ -138,7 +138,7 @@ describe('attributeOpenCodeUsageEvent', () => {
|
||||
it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => {
|
||||
const attributed = await attributeOpenCodeUsageEvent(
|
||||
usageEvent(`${WORKTREE}/..fixtures/session`),
|
||||
worktrees()
|
||||
await resolveWorktree()
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo')
|
||||
@@ -149,7 +149,7 @@ describe('attributeOpenCodeUsageEvent', () => {
|
||||
it('does not attribute true parent-directory escapes to the worktree', async () => {
|
||||
const attributed = await attributeOpenCodeUsageEvent(
|
||||
usageEvent(`${WORKTREE}/../other/session`),
|
||||
worktrees()
|
||||
await resolveWorktree()
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session')
|
||||
@@ -157,15 +157,17 @@ describe('attributeOpenCodeUsageEvent', () => {
|
||||
})
|
||||
|
||||
it('does not treat different Windows drives as containing paths', async () => {
|
||||
const attributed = await attributeOpenCodeUsageEvent(usageEvent('D:\\other\\repo'), [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::C:\\repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'Repo',
|
||||
canonicalPath: 'C:\\repo'
|
||||
}
|
||||
])
|
||||
const attributed = await attributeOpenCodeUsageEvent(
|
||||
usageEvent('D:\\other\\repo'),
|
||||
await createUsageWorktreeResolver([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::C:\\repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'Repo'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(attributed?.projectKey).toBe('cwd:d:/other/repo')
|
||||
expect(attributed?.worktreeId).toBeNull()
|
||||
@@ -222,7 +224,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, await resolveWorktree())
|
||||
|
||||
expect(parsed.sessions).toHaveLength(1)
|
||||
expect(parsed.sessions[0]).toMatchObject({
|
||||
@@ -292,7 +294,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, await resolveWorktree())
|
||||
|
||||
expect(parsed.sessions[0]).toMatchObject({
|
||||
primaryModel: 'openai/gpt-5.5',
|
||||
@@ -308,7 +310,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
insertSessionTotalsRow(db, 'session-1', 1000)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, await resolveWorktree())
|
||||
|
||||
expect(parsed.ownedSessionIds).toEqual(['session-1'])
|
||||
})
|
||||
@@ -372,7 +374,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, await resolveWorktree())
|
||||
|
||||
expect(parsed.sessions[0]?.totalTokens).toBe(120)
|
||||
expect(parsed.sessions[0]?.eventCount).toBe(1)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
|
||||
import {
|
||||
createUsageWorktreeResolver,
|
||||
type UsageWorktreeResolver
|
||||
} from '../usage/usage-worktree-resolver'
|
||||
import {
|
||||
compareOpenCodeClaimPriority,
|
||||
getProcessedDatabaseInfo,
|
||||
@@ -10,7 +14,6 @@ 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 {
|
||||
@@ -50,7 +53,7 @@ const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregat
|
||||
|
||||
export async function parseOpenCodeUsageDatabase(
|
||||
dbPath: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[],
|
||||
resolveWorktree: UsageWorktreeResolver,
|
||||
options: { claimSession?: (sessionId: string) => boolean } = {}
|
||||
): Promise<OpenCodeUsagePersistedDatabase> {
|
||||
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
|
||||
@@ -76,7 +79,7 @@ export async function parseOpenCodeUsageDatabase(
|
||||
hasDeferredClaims = true
|
||||
continue
|
||||
}
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, resolveWorktree)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
}
|
||||
@@ -106,7 +109,8 @@ export async function scanOpenCodeUsageDatabases(
|
||||
const previousByPath = new Map(
|
||||
previousProcessedDatabases.map((database) => [database.path, database])
|
||||
)
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
// Why: one resolver for the whole scan so every database shares the per-cwd memo.
|
||||
const resolveWorktree = await createUsageWorktreeResolver(worktrees)
|
||||
|
||||
const currentPaths = new Set(dbPaths)
|
||||
// Why: when a database that owned sessions is deleted, remaining siblings
|
||||
@@ -182,7 +186,7 @@ export async function scanOpenCodeUsageDatabases(
|
||||
const parsedByPath = new Map<string, OpenCodeUsagePersistedDatabase>()
|
||||
const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority)
|
||||
for (const [index, dbPath] of orderedPathsToParse.entries()) {
|
||||
const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, {
|
||||
const processed = await parseOpenCodeUsageDatabase(dbPath, resolveWorktree, {
|
||||
claimSession: (sessionId) => {
|
||||
const owner = sessionOwnerById.get(sessionId)
|
||||
if (owner !== undefined && owner !== dbPath) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeLogic from '../ipc/worktree-logic'
|
||||
import type { UsageScanWorktreeRef } from './usage-provider-contract'
|
||||
|
||||
const { worktreePathComparisons } = vi.hoisted(() => ({
|
||||
worktreePathComparisons: { count: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorktreeLogic>()
|
||||
return {
|
||||
...actual,
|
||||
areWorktreePathsEqual: (left: string, right: string) => {
|
||||
worktreePathComparisons.count += 1
|
||||
return actual.areWorktreePathsEqual(left, right)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { createUsageWorktreeResolver } from './usage-worktree-resolver'
|
||||
|
||||
function worktree(path: string, index: number): UsageScanWorktreeRef {
|
||||
return {
|
||||
repoId: `repo-${index}`,
|
||||
worktreeId: `repo-${index}::${path}`,
|
||||
path,
|
||||
displayName: `Repo ${index}`
|
||||
}
|
||||
}
|
||||
|
||||
describe('createUsageWorktreeResolver', () => {
|
||||
beforeEach(() => {
|
||||
worktreePathComparisons.count = 0
|
||||
})
|
||||
|
||||
it('walks the worktree list once per distinct cwd, including misses', async () => {
|
||||
const resolveWorktree = await createUsageWorktreeResolver(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
worktree(`/repo-${String(index).padStart(3, '0')}`, index)
|
||||
)
|
||||
)
|
||||
const attribute = (event: number): string | null => {
|
||||
const cwd = event % 2 === 0 ? '/repo-049/nested/pkg' : '/outside/project'
|
||||
return resolveWorktree(cwd)?.worktreeId ?? null
|
||||
}
|
||||
|
||||
expect(attribute(0)).toBe('repo-49::/repo-049')
|
||||
expect(attribute(1)).toBeNull()
|
||||
const afterFirstOfEachCwd = worktreePathComparisons.count
|
||||
expect(afterFirstOfEachCwd).toBeGreaterThan(0)
|
||||
expect(afterFirstOfEachCwd).toBeLessThanOrEqual(100)
|
||||
|
||||
for (let event = 2; event < 1_000; event++) {
|
||||
expect(attribute(event)).toBe(event % 2 === 0 ? 'repo-49::/repo-049' : null)
|
||||
}
|
||||
|
||||
// Two distinct cwds walked the list once each; 998 more events cost nothing.
|
||||
expect(worktreePathComparisons.count).toBe(afterFirstOfEachCwd)
|
||||
})
|
||||
|
||||
it('keeps containment semantics unchanged', async () => {
|
||||
const resolveWorktree = await createUsageWorktreeResolver([worktree('/workspace/repo', 1)])
|
||||
|
||||
expect(resolveWorktree('/workspace/repo')?.worktreeId).toBe('repo-1::/workspace/repo')
|
||||
expect(resolveWorktree('/workspace/repo/packages/app')?.worktreeId).toBe(
|
||||
'repo-1::/workspace/repo'
|
||||
)
|
||||
// `..name` is a child directory; `..` escapes.
|
||||
expect(resolveWorktree('/workspace/repo/..fixtures/session')?.worktreeId).toBe(
|
||||
'repo-1::/workspace/repo'
|
||||
)
|
||||
expect(resolveWorktree('/workspace/repo/../other/session')).toBeNull()
|
||||
expect(resolveWorktree('/workspace/repo-sibling')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a different Windows drive as contained', async () => {
|
||||
const resolveWorktree = await createUsageWorktreeResolver([worktree('C:\\repo', 1)])
|
||||
|
||||
expect(resolveWorktree('C:\\repo\\packages\\app')?.worktreeId).toBe('repo-1::C:\\repo')
|
||||
expect(resolveWorktree('D:\\other\\repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves nothing when no worktree is known', async () => {
|
||||
const resolveWorktree = await createUsageWorktreeResolver([])
|
||||
expect(resolveWorktree('/workspace/repo')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
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, normalizeFsPath } from './usage-path-comparison'
|
||||
import type { UsageScanWorktreeRef } from './usage-provider-contract'
|
||||
|
||||
type CanonicalizedUsageWorktreeRef = UsageScanWorktreeRef & { canonicalPath: string }
|
||||
|
||||
/** Maps an event's `cwd` to the worktree that contains it, or null when it is outside every one. */
|
||||
export type UsageWorktreeResolver = (cwd: string) => UsageScanWorktreeRef | null
|
||||
|
||||
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
|
||||
}
|
||||
// Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an
|
||||
// absolute `D:\\other` path instead of a `..`-prefixed relative. Treating
|
||||
// that as "contained" would attribute off-drive usage to the wrong
|
||||
// Orca worktree.
|
||||
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 canonicalizePath(pathValue: string): Promise<string> {
|
||||
try {
|
||||
return normalizeFsPath(await realpath(pathValue))
|
||||
} catch {
|
||||
return normalizeFsPath(pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
function findContainingWorktree(
|
||||
cwd: string,
|
||||
worktrees: readonly CanonicalizedUsageWorktreeRef[]
|
||||
): UsageScanWorktreeRef | 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver for one scan, memoized per `cwd`.
|
||||
*
|
||||
* Why: attribution runs per event but a corpus holds only a few hundred distinct cwds, so an
|
||||
* unmemoized search costs `events × worktrees` — 1.6M events against a few hundred remembered
|
||||
* worktrees is minutes of main-thread CPU (STA-7724).
|
||||
*/
|
||||
export async function createUsageWorktreeResolver(
|
||||
worktrees: readonly UsageScanWorktreeRef[]
|
||||
): Promise<UsageWorktreeResolver> {
|
||||
const canonicalized = await canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
||||
const worktreeByCwd = new Map<string, UsageScanWorktreeRef | null>()
|
||||
return (cwd) => {
|
||||
const memoized = worktreeByCwd.get(cwd)
|
||||
// Why: a cwd outside every worktree memoizes as null, so only `undefined` is a miss.
|
||||
if (memoized !== undefined) {
|
||||
return memoized
|
||||
}
|
||||
const resolved = findContainingWorktree(cwd, canonicalized)
|
||||
worktreeByCwd.set(cwd, resolved)
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user