mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day minimum-release-age supply-chain guard; nothing here needs it). The bump is a no-op on the existing config. Enable 3 error rules (backlog autofixed to zero in this commit) and 4 warn rules (surface signal without gating CI): error (autofixed, behavior-preserving): - unicorn/prefer-node-protocol (~1531 sites: bare builtin -> node:) - typescript/no-import-type-side-effects (~36: all-inline-type -> import type) - unicorn/no-array-reverse (19: copy-then-reverse -> toReversed) warn (real signal, current fires are test-only/correct): - unicorn/no-array-fill-with-reference-type (aliasing footgun guard) - typescript/no-unsafe-function-type (bans bare Function type) - unicorn/prefer-array-flat-map (map().flat() -> flatMap()) - unicorn/prefer-regexp-test (.match() in bool ctx -> .test()) mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix ran from root and covered mobile/ too. Verification (all green): oxlint 0 errors (root+mobile+aux configs), oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed, builds (electron-vite + web + cli) succeed. node: rewrites confirmed to skip embedded SSH/CLI string payloads (AST-only); all toReversed sites verified to operate on fresh copies or write-once locals. * chore(lint): bump mobile oxlint to 1.71 so inherited rules parse mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile && oxlint' failed to parse the new rule. Bump mobile to match root (1.71). Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit pass, vitest 978 passed / 0 failed. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
328 lines
9.3 KiB
TypeScript
328 lines
9.3 KiB
TypeScript
/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */
|
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import Database from '../sqlite/sync-database'
|
|
import {
|
|
attributeOpenCodeUsageEvent,
|
|
parseOpenCodeUsageDatabase,
|
|
parseOpenCodeUsageRow
|
|
} from './scanner'
|
|
|
|
const WORKTREE = '/workspace/repo'
|
|
|
|
let tempDirs: string[] = []
|
|
|
|
function createTempDb(): { db: Database.Database; path: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-'))
|
|
tempDirs.push(dir)
|
|
const path = join(dir, 'opencode.db')
|
|
return { db: new Database(path), path }
|
|
}
|
|
|
|
function worktrees() {
|
|
return [
|
|
{
|
|
repoId: 'repo-1',
|
|
worktreeId: 'repo-1::/workspace/repo',
|
|
path: WORKTREE,
|
|
displayName: 'Repo',
|
|
canonicalPath: WORKTREE
|
|
}
|
|
]
|
|
}
|
|
|
|
function usageEvent(cwd: string) {
|
|
return {
|
|
sessionId: 'session-1',
|
|
timestamp: '2026-04-09T10:00:00.000Z',
|
|
cwd,
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
estimatedCostUsd: 0.012,
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 25,
|
|
reasoningOutputTokens: 10,
|
|
totalTokens: 125
|
|
}
|
|
}
|
|
|
|
describe('parseOpenCodeUsageRow', () => {
|
|
it('reads assistant message tokens, cost, model, cwd, and timestamp', () => {
|
|
const parsed = parseOpenCodeUsageRow({
|
|
id: 'message-1',
|
|
session_id: 'session-1',
|
|
time_created: 1_777_777_700_000,
|
|
time_updated: null,
|
|
directory: null,
|
|
title: null,
|
|
worktree: null,
|
|
session_model: null,
|
|
data: JSON.stringify({
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-sonnet-4-5',
|
|
path: { cwd: `${WORKTREE}/packages/app` },
|
|
cost: 0.0123,
|
|
tokens: {
|
|
input: 1000,
|
|
output: 250,
|
|
reasoning: 100,
|
|
total: 1350,
|
|
cache: { read: 400, write: 25 }
|
|
},
|
|
time: {
|
|
completed: 1_777_777_800_000
|
|
}
|
|
})
|
|
})
|
|
|
|
expect(parsed).toEqual({
|
|
sessionId: 'session-1',
|
|
timestamp: new Date(1_777_777_800_000).toISOString(),
|
|
cwd: `${WORKTREE}/packages/app`,
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
estimatedCostUsd: 0.0123,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('attributeOpenCodeUsageEvent', () => {
|
|
it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => {
|
|
const attributed = await attributeOpenCodeUsageEvent(
|
|
usageEvent(`${WORKTREE}/..fixtures/session`),
|
|
worktrees()
|
|
)
|
|
|
|
expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo')
|
|
expect(attributed?.projectLabel).toBe('Repo')
|
|
expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo')
|
|
})
|
|
|
|
it('does not attribute true parent-directory escapes to the worktree', async () => {
|
|
const attributed = await attributeOpenCodeUsageEvent(
|
|
usageEvent(`${WORKTREE}/../other/session`),
|
|
worktrees()
|
|
)
|
|
|
|
expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session')
|
|
expect(attributed?.worktreeId).toBeNull()
|
|
})
|
|
|
|
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'
|
|
}
|
|
])
|
|
|
|
expect(attributed?.projectKey).toBe('cwd:d:/other/repo')
|
|
expect(attributed?.worktreeId).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('parseOpenCodeUsageDatabase', () => {
|
|
afterEach(() => {
|
|
for (const dir of tempDirs) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
tempDirs = []
|
|
})
|
|
|
|
it('uses materialized session token totals when the OpenCode DB has them', async () => {
|
|
const { db, path } = createTempDb()
|
|
db.exec(`
|
|
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT,
|
|
directory TEXT,
|
|
title TEXT,
|
|
model TEXT,
|
|
cost REAL,
|
|
tokens_input INTEGER,
|
|
tokens_output INTEGER,
|
|
tokens_reasoning INTEGER,
|
|
tokens_cache_read INTEGER,
|
|
time_created INTEGER,
|
|
time_updated INTEGER
|
|
);
|
|
`)
|
|
db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE)
|
|
db.prepare(
|
|
`INSERT INTO session (
|
|
id, project_id, directory, title, model, cost,
|
|
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
|
time_created, time_updated
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
).run(
|
|
'session-1',
|
|
'project-1',
|
|
`${WORKTREE}/packages/app`,
|
|
'Build feature',
|
|
JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }),
|
|
0.06,
|
|
1000,
|
|
500,
|
|
100,
|
|
250,
|
|
1_777_777_700_000,
|
|
1_777_777_800_000
|
|
)
|
|
db.close()
|
|
|
|
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
|
|
|
expect(parsed.sessions).toHaveLength(1)
|
|
expect(parsed.sessions[0]).toMatchObject({
|
|
sessionId: 'session-1',
|
|
primaryModel: 'anthropic/claude-sonnet-4-5',
|
|
primaryProjectLabel: 'Repo',
|
|
eventCount: 1,
|
|
totalInputTokens: 1000,
|
|
totalCachedInputTokens: 250,
|
|
totalOutputTokens: 500,
|
|
totalReasoningOutputTokens: 100,
|
|
totalTokens: 1600,
|
|
estimatedCostUsd: 0.06
|
|
})
|
|
expect(parsed.dailyAggregates).toEqual([
|
|
expect.objectContaining({
|
|
projectLabel: 'Repo',
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 250,
|
|
outputTokens: 500,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1600,
|
|
estimatedCostUsd: 0.06
|
|
})
|
|
])
|
|
})
|
|
|
|
it('supports session_message tables without a type column', async () => {
|
|
const { db, path } = createTempDb()
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
directory TEXT,
|
|
title TEXT,
|
|
time_created INTEGER,
|
|
time_updated INTEGER
|
|
);
|
|
CREATE TABLE session_message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
time_created INTEGER,
|
|
time_updated INTEGER,
|
|
data TEXT
|
|
);
|
|
`)
|
|
db.prepare(
|
|
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
|
|
).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000)
|
|
db.prepare(
|
|
'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
|
).run(
|
|
'message-1',
|
|
'session-1',
|
|
1_777_777_700_000,
|
|
1_777_777_800_000,
|
|
JSON.stringify({
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.5',
|
|
cost: 0.03,
|
|
tokens: {
|
|
input: 800,
|
|
output: 200,
|
|
reasoning: 50,
|
|
cache: { read: 100, write: 0 }
|
|
}
|
|
})
|
|
)
|
|
db.close()
|
|
|
|
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
|
|
|
expect(parsed.sessions[0]).toMatchObject({
|
|
primaryModel: 'openai/gpt-5.5',
|
|
primaryProjectLabel: 'Repo',
|
|
totalTokens: 1050,
|
|
estimatedCostUsd: 0.03
|
|
})
|
|
})
|
|
|
|
it('prefers session_message rows over legacy message rows to avoid double counting', async () => {
|
|
const { db, path } = createTempDb()
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
directory TEXT,
|
|
title TEXT,
|
|
time_created INTEGER,
|
|
time_updated INTEGER
|
|
);
|
|
CREATE TABLE session_message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
type TEXT,
|
|
time_created INTEGER,
|
|
time_updated INTEGER,
|
|
data TEXT
|
|
);
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
time_created INTEGER,
|
|
time_updated INTEGER,
|
|
data TEXT
|
|
);
|
|
`)
|
|
db.prepare(
|
|
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
|
|
).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000)
|
|
db.prepare(
|
|
'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
'session-message-1',
|
|
'session-1',
|
|
'assistant',
|
|
1_777_777_700_000,
|
|
1_777_777_800_000,
|
|
JSON.stringify({
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.5',
|
|
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } }
|
|
})
|
|
)
|
|
db.prepare(
|
|
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
|
).run(
|
|
'legacy-message-1',
|
|
'session-1',
|
|
1_777_777_700_000,
|
|
1_777_777_800_000,
|
|
JSON.stringify({
|
|
role: 'assistant',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.5',
|
|
tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } }
|
|
})
|
|
)
|
|
db.close()
|
|
|
|
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
|
|
|
expect(parsed.sessions[0]?.totalTokens).toBe(120)
|
|
expect(parsed.sessions[0]?.eventCount).toBe(1)
|
|
})
|
|
})
|