mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-migration-check
# Conflicts: # src/main/persistence.ts
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.2-rc.8",
|
||||
"version": "1.4.2-rc.9",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
+10
-2
@@ -47,11 +47,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
|
||||
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
||||
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
||||
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
||||
// Why: pass `null` (not `undefined`) when remote selection is suppressed
|
||||
// so the RuntimeClient default parameter does not re-activate the
|
||||
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
|
||||
// that must run locally (environment / serve).
|
||||
const client = new RuntimeClient(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : undefined,
|
||||
typeof environmentSelector === 'string' ? environmentSelector : undefined
|
||||
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
|
||||
typeof environmentSelector === 'string'
|
||||
? environmentSelector
|
||||
: ignoreRemoteSelection
|
||||
? null
|
||||
: undefined
|
||||
)
|
||||
await dispatch(parsed.commandPath, {
|
||||
flags: parsed.flags,
|
||||
|
||||
@@ -290,6 +290,50 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
server.registerPaneKeyAlias('tab-1:0', PANE)
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody(
|
||||
{
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'legacy pane'
|
||||
},
|
||||
{ paneKey: 'tab-1:0' }
|
||||
)
|
||||
)
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'legacy pane',
|
||||
agentType: 'claude'
|
||||
})
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('tracks hook posts with an empty paneKey before dropping them', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
@@ -2127,6 +2171,112 @@ describe('Last-status persistence', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab-1:0': {
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt: recentTs(),
|
||||
stateStartedAt: recentTs(-1000),
|
||||
payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
const server = new AgentHookServer()
|
||||
server.registerPaneKeyAlias('tab-1:0', PANE)
|
||||
await server.start({
|
||||
env: 'production',
|
||||
userDataPath
|
||||
})
|
||||
try {
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
state: 'working',
|
||||
prompt: 'legacy cached',
|
||||
agentType: 'claude'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab-1:0': {
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt: recentTs(),
|
||||
stateStartedAt: recentTs(-1000),
|
||||
payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
const server = new AgentHookServer()
|
||||
const statusListener = vi.fn()
|
||||
server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1')
|
||||
server.subscribeStatusChanges(statusListener)
|
||||
await server.start({
|
||||
env: 'production',
|
||||
userDataPath
|
||||
})
|
||||
try {
|
||||
expect(server.getStatusSnapshot()).toHaveLength(1)
|
||||
|
||||
server.clearPaneKeyAliasesForPty('pty-1')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
expect(statusListener).toHaveBeenCalledWith([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear a stable status when alias cleanup no longer owns that pane', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty')
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
agentType: 'claude'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -2318,6 +2468,40 @@ describe('AgentHookServer ingestRemote', () => {
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('maps registered legacy numeric relay pane keys to stable pane keys', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.registerPaneKeyAlias('tab-1:0', PANE)
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'conn-1',
|
||||
payload
|
||||
})
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
state: 'working',
|
||||
prompt: 'p'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
|
||||
+159
-11
@@ -39,7 +39,8 @@ import {
|
||||
type AgentStatusState,
|
||||
normalizeAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
|
||||
|
||||
export type { AgentHookSource }
|
||||
|
||||
@@ -63,6 +64,12 @@ export type AgentHookStatusChangeEntry = {
|
||||
}
|
||||
|
||||
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
||||
type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void
|
||||
type PaneKeyAliasEntry = {
|
||||
stablePaneKey: string
|
||||
ptyId: string | null
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// Why: name of the on-disk cache that survives Orca restart. Lives next to
|
||||
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
|
||||
@@ -215,6 +222,8 @@ export class AgentHookServer {
|
||||
// Why: hydrated last-status rows are useful UI continuity, but they are not
|
||||
// evidence of live agent work in this main-process runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: full path to the on-disk last-status cache. Set in start() from
|
||||
// userDataPath. Null when the server runs without a userDataPath (e.g.
|
||||
// tests that skip the userDataPath option) — in that case, persistence is
|
||||
@@ -305,6 +314,121 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
|
||||
this.paneKeyAliasPersistenceListener = listener
|
||||
}
|
||||
|
||||
private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] {
|
||||
return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) =>
|
||||
entry.ptyId
|
||||
? [
|
||||
{
|
||||
ptyId: entry.ptyId,
|
||||
legacyPaneKey,
|
||||
stablePaneKey: entry.stablePaneKey,
|
||||
updatedAt: entry.updatedAt
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
private notifyPaneKeyAliasPersistenceListener(): void {
|
||||
this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases())
|
||||
}
|
||||
|
||||
registerPaneKeyAlias(
|
||||
legacyPaneKey: string,
|
||||
stablePaneKey: string,
|
||||
ptyId?: string,
|
||||
updatedAt = Date.now(),
|
||||
options?: { overwriteExisting?: boolean }
|
||||
): void {
|
||||
const legacy = parseLegacyNumericPaneKey(legacyPaneKey)
|
||||
const stable = parsePaneKey(stablePaneKey)
|
||||
if (!legacy || !stable || legacy.tabId !== stable.tabId) {
|
||||
return
|
||||
}
|
||||
const existing = this.legacyPaneKeyAliases.get(legacy.paneKey)
|
||||
if (existing && options?.overwriteExisting === false) {
|
||||
return
|
||||
}
|
||||
const normalizedPtyId =
|
||||
typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId
|
||||
const normalizedUpdatedAt =
|
||||
Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now())
|
||||
if (
|
||||
existing &&
|
||||
existing.stablePaneKey === stablePaneKey &&
|
||||
existing.ptyId === (normalizedPtyId ?? null) &&
|
||||
existing.updatedAt === normalizedUpdatedAt
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.legacyPaneKeyAliases.set(legacy.paneKey, {
|
||||
stablePaneKey,
|
||||
ptyId: normalizedPtyId ?? null,
|
||||
updatedAt: normalizedUpdatedAt
|
||||
})
|
||||
if (normalizedPtyId) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
}
|
||||
|
||||
clearPaneKeyAliasesForPty(
|
||||
ptyId: string,
|
||||
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
|
||||
): void {
|
||||
let aliasChanged = false
|
||||
let statusChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (entry.ptyId === ptyId) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
const shouldClearStablePaneKey =
|
||||
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
|
||||
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
||||
statusChanged = true
|
||||
}
|
||||
if (shouldClearStablePaneKey) {
|
||||
// Why: after hydrate, legacy rows are stored under the stable key. If
|
||||
// this PTY is later proven dead before ptyPaneKey is rebuilt, alias
|
||||
// cleanup is the only path that can evict that retained status.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
}
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (statusChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePaneKeyAlias(paneKey: string): string {
|
||||
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
|
||||
}
|
||||
|
||||
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return body
|
||||
}
|
||||
const record = body as Record<string, unknown>
|
||||
const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
|
||||
const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey
|
||||
if (!stablePaneKey) {
|
||||
return body
|
||||
}
|
||||
// Why: pre-migration live shells keep posting their immutable numeric
|
||||
// ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this
|
||||
// bridge lets hook caches and renderer state use only the stable key.
|
||||
return { ...record, paneKey: stablePaneKey }
|
||||
}
|
||||
|
||||
/** Ingest a payload that arrived over the relay JSON-RPC channel rather
|
||||
* than the local HTTP server. `connectionId` is the SshChannelMultiplexer
|
||||
* identity Orca holds (the wire envelope carries connectionId: null and
|
||||
@@ -341,7 +465,7 @@ export class AgentHookServer {
|
||||
// Why: match the listener's HTTP path — `normalizeHookPayload` trims and
|
||||
// length-caps paneKey before caching, so the cache key here must follow
|
||||
// the same rule or remote-vs-local events for the same pane would diverge.
|
||||
const paneKey = envelope.paneKey.trim()
|
||||
const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim())
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
if (paneKey.length === 0) {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
@@ -460,7 +584,12 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
trackEmptyPaneKeyHook(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, body, this.env)
|
||||
const normalized = normalizeHookPayload(
|
||||
this.state,
|
||||
source,
|
||||
this.normalizeHookBodyPaneKeyAlias(body),
|
||||
this.env
|
||||
)
|
||||
if (normalized) {
|
||||
const enriched = this.attachStatusTiming(normalized)
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
@@ -527,6 +656,7 @@ export class AgentHookServer {
|
||||
this.lastStatusFilePath = null
|
||||
this.lastWrittenJson = null
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -539,24 +669,37 @@ export class AgentHookServer {
|
||||
* lands. clearPaneState (which wipes all three caches) is the right shape
|
||||
* only for PTY-teardown. */
|
||||
dropStatusEntry(paneKey: string): void {
|
||||
if (!this.state.lastStatusByPaneKey.has(paneKey)) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) {
|
||||
return
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
// Why: only schedule a write when we actually evicted a status entry —
|
||||
// dropping prompt/tool caches for a pane that never produced a hook
|
||||
// event does not change the on-disk file, and skipping the write avoids
|
||||
// re-stat'ing on every dead-pane teardown.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
if (clearedAlias) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -655,9 +798,14 @@ export class AgentHookServer {
|
||||
// tick.
|
||||
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
||||
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
||||
const entry = sanitizeHydratedEntry(paneKey, rawEntry)
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const rawResolvedEntry =
|
||||
resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null
|
||||
? rawEntry
|
||||
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
|
||||
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
|
||||
if (entry && entry.receivedAt >= ttlCutoff) {
|
||||
this.state.lastStatusByPaneKey.set(paneKey, entry)
|
||||
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
|
||||
hydrated += 1
|
||||
} else {
|
||||
dropped += 1
|
||||
|
||||
@@ -88,4 +88,105 @@ describe('AutomationService', () => {
|
||||
new Date('2026-05-14T09:00:00').getTime()
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Costed check',
|
||||
prompt: 'Check spend',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
const usage = {
|
||||
status: 'known' as const,
|
||||
provider: 'claude' as const,
|
||||
model: 'claude-sonnet-4-6',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 25,
|
||||
cacheWriteTokens: 10,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: 185,
|
||||
estimatedCostUsd: 0.001,
|
||||
estimatedCostSource: 'api_equivalent' as const,
|
||||
providerSessionId: 'provider-session-1',
|
||||
attribution: 'provider_session_time_window' as const,
|
||||
collectedAt: Date.now(),
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null
|
||||
}
|
||||
const getAutomationRunUsage = vi.fn().mockResolvedValue(usage)
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
claudeUsage: { getAutomationRunUsage } as never
|
||||
})
|
||||
|
||||
const updated = await service.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'completed',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
|
||||
expect(updated.usage).toEqual(usage)
|
||||
expect(getAutomationRunUsage).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('records unsupported usage cleanly for completed agents without local usage stores', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Gemini check',
|
||||
prompt: 'Check spend',
|
||||
agentId: 'gemini',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
|
||||
const updated = await service.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'completed',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
|
||||
expect(updated.usage?.status).toBe('unavailable')
|
||||
expect(updated.usage?.unavailableReason).toBe('provider_unsupported')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,12 @@ import type {
|
||||
Automation,
|
||||
AutomationDispatchRequest,
|
||||
AutomationDispatchResult,
|
||||
AutomationRun
|
||||
AutomationRun,
|
||||
AutomationRunStatus,
|
||||
AutomationRunUsage
|
||||
} from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -16,10 +20,17 @@ export class AutomationService {
|
||||
private webContents: WebContents | null = null
|
||||
private rendererReady = false
|
||||
private evaluating = false
|
||||
private readonly claudeUsage: ClaudeUsageStore | null
|
||||
private readonly codexUsage: CodexUsageStore | null
|
||||
|
||||
constructor(store: Store, opts: { tickMs?: number } = {}) {
|
||||
constructor(
|
||||
store: Store,
|
||||
opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {}
|
||||
) {
|
||||
this.store = store
|
||||
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
|
||||
this.claudeUsage = opts.claudeUsage ?? null
|
||||
this.codexUsage = opts.codexUsage ?? null
|
||||
}
|
||||
|
||||
setWebContents(webContents: WebContents | null): void {
|
||||
@@ -62,8 +73,97 @@ export class AutomationService {
|
||||
return run
|
||||
}
|
||||
|
||||
markDispatchResult(result: AutomationDispatchResult): AutomationRun {
|
||||
return this.store.updateAutomationRun(result)
|
||||
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
|
||||
const run = this.store.updateAutomationRun(result)
|
||||
if (!isFinalRunStatus(run.status)) {
|
||||
return run
|
||||
}
|
||||
const usage = await this.collectRunUsage(run)
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
workspaceId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
usage,
|
||||
error: run.error
|
||||
})
|
||||
}
|
||||
|
||||
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
|
||||
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage => ({
|
||||
status: 'unavailable',
|
||||
provider,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
})
|
||||
|
||||
if (!automation || run.status !== 'completed') {
|
||||
return unavailable(
|
||||
automation?.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation?.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'run_not_finished',
|
||||
'Usage is only collected for completed automation runs.'
|
||||
)
|
||||
}
|
||||
if (automation.executionTargetType === 'ssh') {
|
||||
return unavailable(
|
||||
automation.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'remote_usage_unavailable',
|
||||
'Remote automation usage is not available from local usage logs.'
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'claude') {
|
||||
if (!this.claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return this.claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!this.codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return this.codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
return unavailable(
|
||||
null,
|
||||
'provider_unsupported',
|
||||
'This agent does not report usage to Orca yet.'
|
||||
)
|
||||
}
|
||||
|
||||
private async evaluateDueRuns(): Promise<void> {
|
||||
@@ -128,3 +228,13 @@ export class AutomationService {
|
||||
webContents.send('automations:dispatchRequested', payload)
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalRunStatus(status: AutomationRunStatus): boolean {
|
||||
return (
|
||||
status === 'completed' ||
|
||||
status === 'dispatch_failed' ||
|
||||
status === 'skipped_missed' ||
|
||||
status === 'skipped_unavailable' ||
|
||||
status === 'skipped_needs_interactive_auth'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,4 +257,66 @@ describe('ClaudeUsageStore', () => {
|
||||
|
||||
expect(summary.estimatedCostUsd).toBeCloseTo(8.07)
|
||||
})
|
||||
|
||||
it('returns automation usage for a single matching worktree session', async () => {
|
||||
const worktreeId = 'repo-1::/workspace/repo-a'
|
||||
const store = createStoreWithState({
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: 1,
|
||||
lastScanCompletedAt: 2,
|
||||
lastScanError: null
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
firstTimestamp: '2026-04-09T15:00:00.000Z',
|
||||
lastTimestamp: '2026-04-09T15:05:00.000Z',
|
||||
model: 'claude-sonnet-4-6',
|
||||
lastCwd: '/workspace/repo-a',
|
||||
lastGitBranch: 'feature/a',
|
||||
primaryWorktreeId: worktreeId,
|
||||
primaryRepoId: 'repo-1',
|
||||
turnCount: 1,
|
||||
totalInputTokens: 1000,
|
||||
totalOutputTokens: 500,
|
||||
totalCacheReadTokens: 200,
|
||||
totalCacheWriteTokens: 100,
|
||||
locationBreakdown: [
|
||||
{
|
||||
locationKey: `worktree:${worktreeId}`,
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId,
|
||||
turnCount: 1,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cacheReadTokens: 200,
|
||||
cacheWriteTokens: 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: 1,
|
||||
lastScanCompletedAt: 2,
|
||||
lastScanError: null,
|
||||
hasAnyClaudeData: true
|
||||
})
|
||||
|
||||
const usage = await store.getAutomationRunUsage({
|
||||
worktreeId,
|
||||
terminalSessionId: 'tab-1',
|
||||
startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(),
|
||||
completedAt: new Date('2026-04-09T15:06:00.000Z').getTime()
|
||||
})
|
||||
|
||||
expect(usage.status).toBe('known')
|
||||
expect(usage.providerSessionId).toBe('session-1')
|
||||
expect(usage.totalTokens).toBe(1800)
|
||||
expect(usage.estimatedCostUsd).toBeCloseTo(0.010935)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ClaudeUsageSessionRow,
|
||||
ClaudeUsageSummary
|
||||
} from '../../shared/claude-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
@@ -19,6 +20,7 @@ import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
// Why: capture the path after configureDevUserDataPath() but before app.setName()
|
||||
// mutates Electron's derived userData location, matching the persistence/store pattern.
|
||||
@@ -36,6 +38,13 @@ type ClaudeModelPricing = {
|
||||
cacheWriteAboveThreshold?: number
|
||||
}
|
||||
|
||||
type AutomationUsageLookupInput = {
|
||||
worktreeId: string | null
|
||||
terminalSessionId: string | null
|
||||
startedAt: number | null
|
||||
completedAt: number | null
|
||||
}
|
||||
|
||||
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
|
||||
const SONNET_LONG_CONTEXT_PRICING = {
|
||||
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
|
||||
@@ -622,6 +631,123 @@ export class ClaudeUsageStore {
|
||||
})
|
||||
}
|
||||
|
||||
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage => ({
|
||||
status: 'unavailable',
|
||||
provider: 'claude',
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
})
|
||||
|
||||
if (!this.state.scanState.enabled) {
|
||||
return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.')
|
||||
}
|
||||
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
|
||||
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
|
||||
}
|
||||
|
||||
const scanState = await this.refresh(true)
|
||||
if (scanState.lastScanError) {
|
||||
return unavailable('scan_failed', scanState.lastScanError)
|
||||
}
|
||||
|
||||
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||||
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||||
const candidates = this.state.sessions.filter((session) => {
|
||||
const first = new Date(session.firstTimestamp).getTime()
|
||||
const last = new Date(session.lastTimestamp).getTime()
|
||||
if (!Number.isFinite(first) || !Number.isFinite(last)) {
|
||||
return false
|
||||
}
|
||||
if (session.sessionId === input.terminalSessionId) {
|
||||
return true
|
||||
}
|
||||
if (first < windowStart || first > windowEnd || last > windowEnd) {
|
||||
return false
|
||||
}
|
||||
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return unavailable('no_matching_session', 'No Claude usage session matched this run.')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return unavailable(
|
||||
'ambiguous_session',
|
||||
'Multiple Claude usage sessions matched this run window.'
|
||||
)
|
||||
}
|
||||
|
||||
const session = candidates[0]
|
||||
const scopedLocations = session.locationBreakdown.filter(
|
||||
(entry) => entry.worktreeId === input.worktreeId
|
||||
)
|
||||
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
|
||||
const totals = locations.reduce(
|
||||
(acc, entry) => {
|
||||
acc.turns += entry.turnCount
|
||||
acc.inputTokens += entry.inputTokens
|
||||
acc.outputTokens += entry.outputTokens
|
||||
acc.cacheReadTokens += entry.cacheReadTokens
|
||||
acc.cacheWriteTokens += entry.cacheWriteTokens
|
||||
return acc
|
||||
},
|
||||
{
|
||||
turns: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
)
|
||||
const estimatedCostUsd = estimateCostUsd(
|
||||
session.model,
|
||||
totals.inputTokens,
|
||||
totals.outputTokens,
|
||||
totals.cacheReadTokens,
|
||||
totals.cacheWriteTokens
|
||||
)
|
||||
|
||||
return {
|
||||
status: 'known',
|
||||
provider: 'claude',
|
||||
model: session.model,
|
||||
inputTokens: totals.inputTokens,
|
||||
outputTokens: totals.outputTokens,
|
||||
cacheReadTokens: totals.cacheReadTokens,
|
||||
cacheWriteTokens: totals.cacheWriteTokens,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens:
|
||||
totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens,
|
||||
estimatedCostUsd,
|
||||
estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent',
|
||||
providerSessionId: session.sessionId,
|
||||
// Why: Orca terminal tab ids and Claude usage session ids are different
|
||||
// systems today, so attribution is intentionally limited to one local
|
||||
// provider session in the run's worktree/time window.
|
||||
attribution: 'provider_session_time_window',
|
||||
collectedAt,
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((entry) => {
|
||||
|
||||
@@ -661,4 +661,101 @@ describe('CodexUsageStore', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns automation usage for a single matching worktree session', async () => {
|
||||
const worktreeId = 'repo-1::/workspace/repo'
|
||||
const store = createStoreWithState({
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: 1,
|
||||
lastScanCompletedAt: 2,
|
||||
lastScanError: null
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
firstTimestamp: '2026-04-10T15:00:00.000Z',
|
||||
lastTimestamp: '2026-04-10T15:05:00.000Z',
|
||||
primaryModel: 'gpt-5',
|
||||
hasMixedModels: false,
|
||||
primaryProjectLabel: 'Repo',
|
||||
hasMixedLocations: false,
|
||||
primaryWorktreeId: worktreeId,
|
||||
primaryRepoId: 'repo-1',
|
||||
eventCount: 1,
|
||||
totalInputTokens: 1000,
|
||||
totalCachedInputTokens: 400,
|
||||
totalOutputTokens: 250,
|
||||
totalReasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false,
|
||||
locationBreakdown: [
|
||||
{
|
||||
locationKey: `worktree:${worktreeId}`,
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false
|
||||
}
|
||||
],
|
||||
modelBreakdown: [
|
||||
{
|
||||
modelKey: 'gpt-5',
|
||||
modelLabel: 'gpt-5',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false
|
||||
}
|
||||
],
|
||||
locationModelBreakdown: [
|
||||
{
|
||||
locationKey: `worktree:${worktreeId}`,
|
||||
modelKey: 'gpt-5',
|
||||
modelLabel: 'gpt-5',
|
||||
repoId: 'repo-1',
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1250,
|
||||
hasInferredPricing: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: 1,
|
||||
lastScanCompletedAt: 2,
|
||||
lastScanError: null,
|
||||
hasAnyCodexData: true
|
||||
})
|
||||
|
||||
const usage = await store.getAutomationRunUsage({
|
||||
worktreeId,
|
||||
terminalSessionId: 'tab-1',
|
||||
startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(),
|
||||
completedAt: new Date('2026-04-10T15:06:00.000Z').getTime()
|
||||
})
|
||||
|
||||
expect(usage.status).toBe('known')
|
||||
expect(usage.providerSessionId).toBe('session-1')
|
||||
expect(usage.cacheReadTokens).toBe(400)
|
||||
expect(usage.reasoningOutputTokens).toBe(100)
|
||||
expect(usage.estimatedCostUsd).toBeCloseTo(0.0033)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CodexUsageSessionRow,
|
||||
CodexUsageSummary
|
||||
} from '../../shared/codex-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { CodexUsagePersistedState } from './types'
|
||||
@@ -19,6 +20,7 @@ import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
let _codexUsageFile: string | null = null
|
||||
|
||||
@@ -32,6 +34,13 @@ type CodexModelPricing = {
|
||||
outputTiers?: TieredPrice[]
|
||||
}
|
||||
|
||||
type AutomationUsageLookupInput = {
|
||||
worktreeId: string | null
|
||||
terminalSessionId: string | null
|
||||
startedAt: number | null
|
||||
completedAt: number | null
|
||||
}
|
||||
|
||||
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
|
||||
|
||||
const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
||||
@@ -635,6 +644,154 @@ export class CodexUsageStore {
|
||||
})
|
||||
}
|
||||
|
||||
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage => ({
|
||||
status: 'unavailable',
|
||||
provider: 'codex',
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
})
|
||||
|
||||
if (!this.state.scanState.enabled) {
|
||||
return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.')
|
||||
}
|
||||
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
|
||||
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
|
||||
}
|
||||
|
||||
const scanState = await this.refresh(true)
|
||||
if (scanState.lastScanError) {
|
||||
return unavailable('scan_failed', scanState.lastScanError)
|
||||
}
|
||||
|
||||
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||||
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||||
const candidates = this.state.sessions.filter((session) => {
|
||||
const first = new Date(session.firstTimestamp).getTime()
|
||||
const last = new Date(session.lastTimestamp).getTime()
|
||||
if (!Number.isFinite(first) || !Number.isFinite(last)) {
|
||||
return false
|
||||
}
|
||||
if (session.sessionId === input.terminalSessionId) {
|
||||
return true
|
||||
}
|
||||
if (first < windowStart || first > windowEnd || last > windowEnd) {
|
||||
return false
|
||||
}
|
||||
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return unavailable('no_matching_session', 'No Codex usage session matched this run.')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return unavailable(
|
||||
'ambiguous_session',
|
||||
'Multiple Codex usage sessions matched this run window.'
|
||||
)
|
||||
}
|
||||
|
||||
const session = candidates[0]
|
||||
const scopedLocations = session.locationBreakdown.filter(
|
||||
(entry) => entry.worktreeId === input.worktreeId
|
||||
)
|
||||
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
|
||||
const totals = locations.reduce(
|
||||
(acc, entry) => {
|
||||
acc.events += entry.eventCount
|
||||
acc.inputTokens += entry.inputTokens
|
||||
acc.cachedInputTokens += entry.cachedInputTokens
|
||||
acc.outputTokens += entry.outputTokens
|
||||
acc.reasoningOutputTokens += entry.reasoningOutputTokens
|
||||
acc.totalTokens += entry.totalTokens
|
||||
return acc
|
||||
},
|
||||
{
|
||||
events: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0
|
||||
}
|
||||
)
|
||||
const scopedModelRows = session.locationModelBreakdown.filter(
|
||||
(entry) => entry.worktreeId === input.worktreeId
|
||||
)
|
||||
const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown
|
||||
const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))]
|
||||
let estimatedCostUsd = 0
|
||||
let hasKnownCost = false
|
||||
if (scopedModelRows.length > 0) {
|
||||
for (const modelRow of scopedModelRows) {
|
||||
const cost = estimateCostUsd(
|
||||
modelRow.modelKey,
|
||||
modelRow.inputTokens,
|
||||
modelRow.cachedInputTokens,
|
||||
modelRow.outputTokens
|
||||
)
|
||||
if (cost !== null) {
|
||||
hasKnownCost = true
|
||||
estimatedCostUsd += cost
|
||||
}
|
||||
}
|
||||
} else if (!session.hasMixedModels) {
|
||||
const cost = estimateCostUsd(
|
||||
session.primaryModel,
|
||||
totals.inputTokens,
|
||||
totals.cachedInputTokens,
|
||||
totals.outputTokens
|
||||
)
|
||||
if (cost !== null) {
|
||||
hasKnownCost = true
|
||||
estimatedCostUsd += cost
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'known',
|
||||
provider: 'codex',
|
||||
model:
|
||||
modelLabels.length === 1
|
||||
? modelLabels[0]
|
||||
: session.hasMixedModels
|
||||
? 'Mixed models'
|
||||
: session.primaryModel,
|
||||
inputTokens: totals.inputTokens,
|
||||
outputTokens: totals.outputTokens,
|
||||
cacheReadTokens: totals.cachedInputTokens,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: totals.reasoningOutputTokens,
|
||||
totalTokens: totals.totalTokens,
|
||||
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null,
|
||||
estimatedCostSource: hasKnownCost ? 'api_equivalent' : null,
|
||||
providerSessionId: session.sessionId,
|
||||
// Why: Orca terminal tab ids and Codex usage session ids are different
|
||||
// systems today, so attribution is intentionally limited to one local
|
||||
// provider session in the run's worktree/time window.
|
||||
attribution: 'provider_session_time_window',
|
||||
collectedAt,
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((entry) => {
|
||||
|
||||
+9
-1
@@ -13,6 +13,7 @@ import { Store, initDataPath } from './persistence'
|
||||
import { StatsCollector, initStatsPath } from './stats/collector'
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
|
||||
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
|
||||
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
|
||||
import { killAllPty } from './ipc/pty'
|
||||
import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
@@ -78,6 +79,7 @@ let store: Store | null = null
|
||||
let stats: StatsCollector | null = null
|
||||
let claudeUsage: ClaudeUsageStore | null = null
|
||||
let codexUsage: CodexUsageStore | null = null
|
||||
let openCodeUsage: OpenCodeUsageStore | null = null
|
||||
let codexAccounts: CodexAccountService | null = null
|
||||
let codexRuntimeHome: CodexRuntimeHomeService | null = null
|
||||
let claudeAccounts: ClaudeAccountService | null = null
|
||||
@@ -190,6 +192,7 @@ if (hasSingleInstanceLock) {
|
||||
initStatsPath()
|
||||
initClaudeUsagePath()
|
||||
initCodexUsagePath()
|
||||
initOpenCodeUsagePath()
|
||||
enableMainProcessGpuFeatures()
|
||||
}
|
||||
|
||||
@@ -209,6 +212,9 @@ function openMainWindow(): BrowserWindow {
|
||||
if (!codexUsage) {
|
||||
throw new Error('Codex usage store must be initialized before opening the main window')
|
||||
}
|
||||
if (!openCodeUsage) {
|
||||
throw new Error('OpenCode usage store must be initialized before opening the main window')
|
||||
}
|
||||
if (!rateLimits) {
|
||||
throw new Error('Rate limit service must be initialized before opening the main window')
|
||||
}
|
||||
@@ -272,6 +278,7 @@ function openMainWindow(): BrowserWindow {
|
||||
stats,
|
||||
claudeUsage,
|
||||
codexUsage,
|
||||
openCodeUsage,
|
||||
codexAccounts,
|
||||
claudeAccounts,
|
||||
rateLimits,
|
||||
@@ -664,6 +671,7 @@ app.whenReady().then(async () => {
|
||||
stats = new StatsCollector()
|
||||
claudeUsage = new ClaudeUsageStore(store)
|
||||
codexUsage = new CodexUsageStore(store)
|
||||
openCodeUsage = new OpenCodeUsageStore(store)
|
||||
rateLimits = new RateLimitService()
|
||||
codexRuntimeHome = new CodexRuntimeHomeService(store)
|
||||
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
|
||||
@@ -692,7 +700,7 @@ app.whenReady().then(async () => {
|
||||
// and defeat the teardown helper's prefix sweep (design §4.3 wire-up).
|
||||
getLocalProvider: () => getLocalPtyProvider()
|
||||
})
|
||||
automations = new AutomationService(store)
|
||||
automations = new AutomationService(store, { claudeUsage, codexUsage })
|
||||
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
||||
disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({
|
||||
stats,
|
||||
|
||||
@@ -49,7 +49,8 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ
|
||||
)
|
||||
ipcMain.handle(
|
||||
'automations:markDispatchResult',
|
||||
(_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result)
|
||||
(_event, result: AutomationDispatchResult): Promise<AutomationRun> =>
|
||||
service.markDispatchResult(result)
|
||||
)
|
||||
ipcMain.handle('automations:rendererReady', (): void => {
|
||||
service.setRendererReady()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScope
|
||||
} from '../../shared/opencode-usage-types'
|
||||
|
||||
export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void {
|
||||
ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState())
|
||||
ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) =>
|
||||
openCodeUsage.setEnabled(args.enabled)
|
||||
)
|
||||
ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) =>
|
||||
openCodeUsage.refresh(args?.force ?? false)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'openCodeUsage:getSummary',
|
||||
(_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) =>
|
||||
openCodeUsage.getSummary(args.scope, args.range)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'openCodeUsage:getDaily',
|
||||
(_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) =>
|
||||
openCodeUsage.getDaily(args.scope, args.range)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'openCodeUsage:getBreakdown',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
kind: OpenCodeUsageBreakdownKind
|
||||
}
|
||||
) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'openCodeUsage:getRecentSessions',
|
||||
(_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) =>
|
||||
openCodeUsage.getRecentSessions(args.scope, args.range, args.limit)
|
||||
)
|
||||
}
|
||||
+45
-16
@@ -21,6 +21,7 @@ const {
|
||||
openCodeClearPtyMock,
|
||||
buildAgentHookEnvMock,
|
||||
clearAgentHookPaneStateMock,
|
||||
registerPaneKeyAliasMock,
|
||||
piBuildPtyEnvMock,
|
||||
piClearPtyMock,
|
||||
isPwshAvailableMock,
|
||||
@@ -30,7 +31,8 @@ const {
|
||||
unregisterPtyMock,
|
||||
setMigrationUnsupportedPtyMock,
|
||||
clearMigrationUnsupportedPtyMock,
|
||||
clearMigrationUnsupportedPtysForPaneKeyMock
|
||||
clearMigrationUnsupportedPtysForPaneKeyMock,
|
||||
clearPaneKeyAliasesForPtyMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
@@ -50,6 +52,7 @@ const {
|
||||
openCodeClearPtyMock: vi.fn(),
|
||||
buildAgentHookEnvMock: vi.fn(),
|
||||
clearAgentHookPaneStateMock: vi.fn(),
|
||||
registerPaneKeyAliasMock: vi.fn(),
|
||||
piBuildPtyEnvMock: vi.fn(),
|
||||
piClearPtyMock: vi.fn(),
|
||||
trackMock: vi.fn(),
|
||||
@@ -58,7 +61,8 @@ const {
|
||||
unregisterPtyMock: vi.fn(),
|
||||
setMigrationUnsupportedPtyMock: vi.fn(),
|
||||
clearMigrationUnsupportedPtyMock: vi.fn(),
|
||||
clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn()
|
||||
clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(),
|
||||
clearPaneKeyAliasesForPtyMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -101,7 +105,9 @@ vi.mock('../opencode/hook-service', () => ({
|
||||
vi.mock('../agent-hooks/server', () => ({
|
||||
agentHookServer: {
|
||||
buildPtyEnv: buildAgentHookEnvMock,
|
||||
clearPaneState: clearAgentHookPaneStateMock
|
||||
clearPaneState: clearAgentHookPaneStateMock,
|
||||
registerPaneKeyAlias: registerPaneKeyAliasMock,
|
||||
clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -206,6 +212,7 @@ describe('registerPtyHandlers', () => {
|
||||
openCodeClearPtyMock.mockReset()
|
||||
buildAgentHookEnvMock.mockReset()
|
||||
clearAgentHookPaneStateMock.mockReset()
|
||||
registerPaneKeyAliasMock.mockReset()
|
||||
piBuildPtyEnvMock.mockReset()
|
||||
piClearPtyMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
@@ -216,6 +223,7 @@ describe('registerPtyHandlers', () => {
|
||||
setMigrationUnsupportedPtyMock.mockReset()
|
||||
clearMigrationUnsupportedPtyMock.mockReset()
|
||||
clearMigrationUnsupportedPtysForPaneKeyMock.mockReset()
|
||||
clearPaneKeyAliasesForPtyMock.mockReset()
|
||||
mainWindow.webContents.on.mockReset()
|
||||
mainWindow.webContents.send.mockReset()
|
||||
|
||||
@@ -2326,9 +2334,10 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('registers only validated stable pane keys in the local PTY memory registry', async () => {
|
||||
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const stablePaneKey = makePaneKey('tab-1', leafId)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -2340,22 +2349,17 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
expect(registerPtyMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: null
|
||||
paneKey: stablePaneKey
|
||||
})
|
||||
)
|
||||
expect(setMigrationUnsupportedPtyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ptyId: expect.any(String),
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId,
|
||||
paneKey: makePaneKey('tab-1', leafId),
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local'
|
||||
})
|
||||
expect(registerPaneKeyAliasMock).toHaveBeenCalledWith(
|
||||
'tab-1:0',
|
||||
stablePaneKey,
|
||||
expect.any(String)
|
||||
)
|
||||
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
|
||||
expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled()
|
||||
|
||||
const stablePaneKey = makePaneKey('tab-1', leafId)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -2422,6 +2426,31 @@ describe('registerPtyHandlers', () => {
|
||||
expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey)
|
||||
})
|
||||
|
||||
it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const stablePaneKey = makePaneKey('tab-1', leafId)
|
||||
|
||||
const current = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId,
|
||||
env: { ORCA_PANE_KEY: stablePaneKey }
|
||||
})) as { id: string }
|
||||
|
||||
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id)
|
||||
clearPaneKeyAliasesForPtyMock.mockClear()
|
||||
|
||||
clearProviderPtyState('old-pty-without-forward-pane-key')
|
||||
|
||||
const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find(
|
||||
([ptyId]) => ptyId === 'old-pty-without-forward-pane-key'
|
||||
)?.[1]
|
||||
expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers args.env.SHELL and normalizes the child env after fallback', async () => {
|
||||
const originalShell = process.env.SHELL
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
+33
-45
@@ -38,11 +38,15 @@ import {
|
||||
} from '../../shared/telemetry-events'
|
||||
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
|
||||
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
makePaneKey,
|
||||
parseLegacyNumericPaneKey,
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import {
|
||||
clearMigrationUnsupportedPty,
|
||||
clearMigrationUnsupportedPtysForPaneKey,
|
||||
setMigrationUnsupportedPty
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
@@ -119,28 +123,6 @@ function isValidPaneKey(paneKey: unknown): paneKey is string {
|
||||
return parseValidPaneKey(paneKey) !== null
|
||||
}
|
||||
|
||||
function parseLegacyNumericPaneKey(
|
||||
paneKey: unknown
|
||||
): { tabId: string; numericPaneId: string } | null {
|
||||
if (typeof paneKey !== 'string' || paneKey.length > 256) {
|
||||
return null
|
||||
}
|
||||
const trimmed = paneKey.trim()
|
||||
const delimiter = trimmed.indexOf(':')
|
||||
if (
|
||||
delimiter <= 0 ||
|
||||
delimiter !== trimmed.lastIndexOf(':') ||
|
||||
delimiter === trimmed.length - 1
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const numericPaneId = trimmed.slice(delimiter + 1)
|
||||
if (!/^\d+$/.test(numericPaneId)) {
|
||||
return null
|
||||
}
|
||||
return { tabId: trimmed.slice(0, delimiter), numericPaneId }
|
||||
}
|
||||
|
||||
function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
|
||||
const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : ''
|
||||
if (!isValidPaneKey(normalizedPaneKey)) {
|
||||
@@ -462,19 +444,31 @@ export function clearProviderPtyState(id: string): void {
|
||||
openCodeHookService.clearPty(id)
|
||||
piTitlebarExtensionService.clearPty(id)
|
||||
ptySizes.delete(id)
|
||||
const paneKey = ptyPaneKey.get(id)
|
||||
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
|
||||
// Why: drop the memory-collector registration so a dead PTY does not keep
|
||||
// trying to resolve its (now-dead) pid on every snapshot. Safe no-op for
|
||||
// PTYs that were never registered (SSH-owned).
|
||||
unregisterPty(id)
|
||||
clearMigrationUnsupportedPty(id)
|
||||
agentHookServer.clearPaneKeyAliasesForPty(id, {
|
||||
shouldClearStablePaneKey: (stablePaneKey) => {
|
||||
// Why: when this PTY never rebuilt ptyPaneKey after restart, alias
|
||||
// ownership is our only proof. Once a newer PTY owns the same stable
|
||||
// paneKey, alias teardown must not erase that newer status.
|
||||
const stablePaneOwner = paneKeyPtyId.get(stablePaneKey)
|
||||
if (stablePaneOwner && stablePaneOwner !== id) {
|
||||
return false
|
||||
}
|
||||
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
|
||||
}
|
||||
})
|
||||
rendererSerializerByPtyId.delete(id)
|
||||
// Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would
|
||||
// otherwise accumulate entries for dead panes over the process lifetime.
|
||||
// Use the spawn-time paneKey mapping since the server has no other way to
|
||||
// correlate a ptyId back to its paneKey.
|
||||
const paneKey = ptyPaneKey.get(id)
|
||||
if (paneKey) {
|
||||
const stillOwnsPaneKey = paneKeyPtyId.get(paneKey) === id
|
||||
if (stillOwnsPaneKey) {
|
||||
agentHookServer.clearPaneState(paneKey)
|
||||
paneKeyPtyId.delete(paneKey)
|
||||
@@ -1151,15 +1145,16 @@ export function registerPtyHandlers(
|
||||
isTerminalLeafId(args.leafId)
|
||||
? makePaneKey(args.tabId, args.leafId)
|
||||
: null
|
||||
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
|
||||
const baseEnv = baseEnvWithAuth
|
||||
? { ...baseEnvWithAuth, ...(verifiedPaneKey ? { ORCA_PANE_KEY: verifiedPaneKey } : {}) }
|
||||
? { ...baseEnvWithAuth, ...(stablePaneKey ? { ORCA_PANE_KEY: stablePaneKey } : {}) }
|
||||
: undefined
|
||||
if (baseEnv && !verifiedPaneKey) {
|
||||
if (baseEnv && !stablePaneKey) {
|
||||
// Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the
|
||||
// key proven to match this spawn's tab+leaf may leave the IPC boundary.
|
||||
delete baseEnv.ORCA_PANE_KEY
|
||||
}
|
||||
const validatedPaneKey = verifiedPaneKey
|
||||
const validatedPaneKey = stablePaneKey
|
||||
const validatedLeafId = verifiedLeafId ?? metadataLeafId
|
||||
let env: Record<string, string> | undefined = baseEnv
|
||||
const preAllocatedHandle =
|
||||
@@ -1445,24 +1440,17 @@ export function registerPtyHandlers(
|
||||
const rememberedPaneKey = validatedPaneKey
|
||||
? rememberPaneKeyForPty(result.id, validatedPaneKey)
|
||||
: null
|
||||
if (validatedPaneKey) {
|
||||
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
legacySpawnPaneKey.paneKey,
|
||||
migrationUnsupportedPaneKey,
|
||||
result.id
|
||||
)
|
||||
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
|
||||
} else if (validatedPaneKey) {
|
||||
if (!result.isReattach) {
|
||||
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
|
||||
}
|
||||
} else if (migrationUnsupportedPaneKey && legacySpawnPaneKey) {
|
||||
// Why: old live PTYs can still carry `${tabId}:${numericPaneId}` in
|
||||
// ORCA_PANE_KEY. Only surface a migration row when this spawn also
|
||||
// proves the owning UUID leaf through the renderer IPC metadata.
|
||||
setMigrationUnsupportedPty({
|
||||
ptyId: result.id,
|
||||
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
|
||||
tabId: legacySpawnPaneKey.tabId,
|
||||
leafId: args.leafId,
|
||||
paneKey: migrationUnsupportedPaneKey,
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: args.connectionId ? 'ssh' : 'local',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
// Why: register local PTYs (connectionId falsy) with the memory
|
||||
// collector so it can walk each PTY's process subtree and attribute
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
registerPreflightHandlersMock,
|
||||
registerClaudeUsageHandlersMock,
|
||||
registerCodexUsageHandlersMock,
|
||||
registerOpenCodeUsageHandlersMock,
|
||||
registerGitHubHandlersMock,
|
||||
registerFeedbackHandlersMock,
|
||||
registerStatsHandlersMock,
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
registerClaudeUsageHandlersMock: vi.fn(),
|
||||
registerCodexUsageHandlersMock: vi.fn(),
|
||||
registerOpenCodeUsageHandlersMock: vi.fn(),
|
||||
registerGitHubHandlersMock: vi.fn(),
|
||||
registerFeedbackHandlersMock: vi.fn(),
|
||||
registerStatsHandlersMock: vi.fn(),
|
||||
@@ -110,6 +112,10 @@ vi.mock('./codex-usage', () => ({
|
||||
registerCodexUsageHandlers: registerCodexUsageHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-usage', () => ({
|
||||
registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./github', () => ({
|
||||
registerGitHubHandlers: registerGitHubHandlersMock
|
||||
}))
|
||||
@@ -245,6 +251,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerPreflightHandlersMock.mockReset()
|
||||
registerClaudeUsageHandlersMock.mockReset()
|
||||
registerCodexUsageHandlersMock.mockReset()
|
||||
registerOpenCodeUsageHandlersMock.mockReset()
|
||||
registerGitHubHandlersMock.mockReset()
|
||||
registerFeedbackHandlersMock.mockReset()
|
||||
registerStatsHandlersMock.mockReset()
|
||||
@@ -288,6 +295,7 @@ describe('registerCoreHandlers', () => {
|
||||
const stats = { marker: 'stats' }
|
||||
const claudeUsage = { marker: 'claudeUsage' }
|
||||
const codexUsage = { marker: 'codexUsage' }
|
||||
const openCodeUsage = { marker: 'openCodeUsage' }
|
||||
const codexAccounts = { marker: 'codexAccounts' }
|
||||
const claudeAccounts = { marker: 'claudeAccounts' }
|
||||
const rateLimits = { marker: 'rateLimits' }
|
||||
@@ -298,6 +306,7 @@ describe('registerCoreHandlers', () => {
|
||||
stats as never,
|
||||
claudeUsage as never,
|
||||
codexUsage as never,
|
||||
openCodeUsage as never,
|
||||
codexAccounts as never,
|
||||
claudeAccounts as never,
|
||||
rateLimits as never
|
||||
@@ -305,6 +314,7 @@ describe('registerCoreHandlers', () => {
|
||||
|
||||
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
|
||||
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
|
||||
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
|
||||
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalled()
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
@@ -348,6 +358,7 @@ describe('registerCoreHandlers', () => {
|
||||
const stats2 = { marker: 'stats2' }
|
||||
const claudeUsage2 = { marker: 'claudeUsage2' }
|
||||
const codexUsage2 = { marker: 'codexUsage2' }
|
||||
const openCodeUsage2 = { marker: 'openCodeUsage2' }
|
||||
const codexAccounts2 = { marker: 'codexAccounts2' }
|
||||
const claudeAccounts2 = { marker: 'claudeAccounts2' }
|
||||
const rateLimits2 = { marker: 'rateLimits2' }
|
||||
@@ -358,6 +369,7 @@ describe('registerCoreHandlers', () => {
|
||||
stats2 as never,
|
||||
claudeUsage2 as never,
|
||||
codexUsage2 as never,
|
||||
openCodeUsage2 as never,
|
||||
codexAccounts2 as never,
|
||||
claudeAccounts2 as never,
|
||||
rateLimits2 as never,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
import { registerClaudeUsageHandlers } from './claude-usage'
|
||||
import { registerCodexUsageHandlers } from './codex-usage'
|
||||
import { registerOpenCodeUsageHandlers } from './opencode-usage'
|
||||
import { registerGitHubHandlers } from './github'
|
||||
import { registerGitLabHandlers } from './gitlab'
|
||||
import { registerHostedReviewHandlers } from './hosted-review'
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
} from '../window/attach-main-window-services'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import type { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type { ClaudeAccountService } from '../claude-accounts/service'
|
||||
@@ -64,6 +66,7 @@ export function registerCoreHandlers(
|
||||
stats: StatsCollector,
|
||||
claudeUsage: ClaudeUsageStore,
|
||||
codexUsage: CodexUsageStore,
|
||||
openCodeUsage: OpenCodeUsageStore,
|
||||
codexAccounts: CodexAccountService,
|
||||
claudeAccounts: ClaudeAccountService,
|
||||
rateLimits: RateLimitService,
|
||||
@@ -88,6 +91,7 @@ export function registerCoreHandlers(
|
||||
registerPreflightHandlers()
|
||||
registerClaudeUsageHandlers(claudeUsage)
|
||||
registerCodexUsageHandlers(codexUsage)
|
||||
registerOpenCodeUsageHandlers(openCodeUsage)
|
||||
registerCodexAccountHandlers(codexAccounts)
|
||||
registerAgentHookHandlers()
|
||||
registerAgentTrustHandlers()
|
||||
|
||||
@@ -100,6 +100,12 @@ export async function searchIssues(
|
||||
}
|
||||
})
|
||||
)
|
||||
// Why: searchIssues returns Linear's relevance ranking. Re-sorting by
|
||||
// updatedAt would discard relevance order for single-workspace results,
|
||||
// diverging from Linear's web UI and pre-PR behavior.
|
||||
if (entries.length === 1) {
|
||||
return results.flat().slice(0, limit)
|
||||
}
|
||||
return sortAndLimitIssues(results.flat(), limit)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,931 @@
|
||||
/* 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 Database from 'better-sqlite3'
|
||||
import { existsSync } from 'fs'
|
||||
import { readdir, realpath, stat } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { isAbsolute, join, posix, win32 } from 'path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsageLocationBreakdown,
|
||||
OpenCodeUsageLocationModelBreakdown,
|
||||
OpenCodeUsageModelBreakdown,
|
||||
OpenCodeUsageParsedEvent,
|
||||
OpenCodeUsagePersistedDatabase,
|
||||
OpenCodeUsageProcessedDatabase,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
|
||||
export type OpenCodeUsageWorktreeRef = {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
path: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type OpenCodeUsageRow = {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number
|
||||
time_updated: number | null
|
||||
data: string
|
||||
directory: string | null
|
||||
title: string | null
|
||||
worktree: string | null
|
||||
session_model: string | null
|
||||
}
|
||||
|
||||
type OpenCodeSessionUsageRow = {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number
|
||||
time_updated: number | null
|
||||
directory: string | null
|
||||
title: string | null
|
||||
worktree: string | null
|
||||
session_model: string | null
|
||||
cost: number
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
}
|
||||
|
||||
const YIELD_EVERY_DATABASES = 2
|
||||
|
||||
function ensureNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function normalizeComparablePath(pathValue: string, platform = process.platform): string {
|
||||
const normalized = pathValue.replace(/\\/g, '/')
|
||||
return platform === 'win32' || looksLikeWindowsPath(pathValue)
|
||||
? normalized.toLowerCase()
|
||||
: normalized
|
||||
}
|
||||
|
||||
function normalizeFsPath(pathValue: string, platform = process.platform): string {
|
||||
if (platform === 'win32' || looksLikeWindowsPath(pathValue)) {
|
||||
return win32.normalize(win32.resolve(pathValue))
|
||||
}
|
||||
return posix.normalize(posix.resolve(pathValue))
|
||||
}
|
||||
|
||||
function looksLikeWindowsPath(pathValue: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function getXdgDataHome(): string {
|
||||
if (process.env.XDG_DATA_HOME?.trim()) {
|
||||
return process.env.XDG_DATA_HOME.trim()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local')
|
||||
}
|
||||
return join(homedir(), '.local', 'share')
|
||||
}
|
||||
|
||||
function getOpenCodeDataDirectory(): string {
|
||||
return join(getXdgDataHome(), 'opencode')
|
||||
}
|
||||
|
||||
function getOpenCodeDatabasePathFromEnv(): string | null {
|
||||
const raw = process.env.OPENCODE_DB?.trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
if (raw === ':memory:') {
|
||||
return null
|
||||
}
|
||||
return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw)
|
||||
}
|
||||
|
||||
export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
const envPath = getOpenCodeDatabasePathFromEnv()
|
||||
if (envPath) {
|
||||
return existsSync(envPath) ? [envPath] : []
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(getOpenCodeDataDirectory(), entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessedDatabaseInfo(
|
||||
dbPath: string
|
||||
): Promise<OpenCodeUsageProcessedDatabase> {
|
||||
const dbStat = await stat(dbPath)
|
||||
return {
|
||||
path: dbPath,
|
||||
mtimeMs: dbStat.mtimeMs,
|
||||
size: dbStat.size
|
||||
}
|
||||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function tableExists(db: Database.Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
|
||||
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 extractString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : 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)
|
||||
return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.'
|
||||
}
|
||||
|
||||
async function buildWorktreesWithCanonicalPaths(
|
||||
worktrees: OpenCodeUsageWorktreeRef[]
|
||||
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
|
||||
const canonicalized = await Promise.all(
|
||||
worktrees.map(async (worktree) => ({
|
||||
...worktree,
|
||||
canonicalPath: await canonicalizePath(worktree.path)
|
||||
}))
|
||||
)
|
||||
return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return (left ?? 0) + (right ?? 0)
|
||||
}
|
||||
|
||||
function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession {
|
||||
return {
|
||||
sessionId: event.sessionId,
|
||||
firstTimestamp: event.timestamp,
|
||||
lastTimestamp: event.timestamp,
|
||||
primaryModel: event.model,
|
||||
hasMixedModels: false,
|
||||
primaryProjectLabel: event.projectLabel,
|
||||
hasMixedLocations: false,
|
||||
primaryWorktreeId: event.worktreeId,
|
||||
primaryRepoId: event.repoId,
|
||||
eventCount: 0,
|
||||
totalInputTokens: 0,
|
||||
totalCachedInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null,
|
||||
locationBreakdown: [],
|
||||
modelBreakdown: [],
|
||||
locationModelBreakdown: []
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDailyAggregate(
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
return {
|
||||
day: event.day,
|
||||
model: event.model,
|
||||
projectKey: event.projectKey,
|
||||
projectLabel: event.projectLabel,
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLocationBreakdown(
|
||||
target: OpenCodeUsageLocationBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
locationKey: event.projectKey,
|
||||
projectLabel: event.projectLabel,
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function mergeModelBreakdown(
|
||||
target: OpenCodeUsageModelBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const key = event.model ?? 'unknown'
|
||||
const existing = target.find((entry) => entry.modelKey === key) ?? null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
modelKey: key,
|
||||
modelLabel: event.model ?? 'Unknown model',
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function mergeLocationModelBreakdown(
|
||||
target: OpenCodeUsageLocationModelBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const modelKey = event.model ?? 'unknown'
|
||||
const existing =
|
||||
target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ??
|
||||
null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
locationKey: event.projectKey,
|
||||
modelKey,
|
||||
modelLabel: event.model ?? 'Unknown model',
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): {
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
} {
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
|
||||
for (const event of events) {
|
||||
const session = sessionsById.get(event.sessionId) ?? createEmptySession(event)
|
||||
if (!sessionsById.has(event.sessionId)) {
|
||||
sessionsById.set(event.sessionId, session)
|
||||
}
|
||||
if (event.timestamp < session.firstTimestamp) {
|
||||
session.firstTimestamp = event.timestamp
|
||||
}
|
||||
if (event.timestamp >= session.lastTimestamp) {
|
||||
session.lastTimestamp = event.timestamp
|
||||
}
|
||||
session.eventCount++
|
||||
session.totalInputTokens += event.inputTokens
|
||||
session.totalCachedInputTokens += event.cachedInputTokens
|
||||
session.totalOutputTokens += event.outputTokens
|
||||
session.totalReasoningOutputTokens += event.reasoningOutputTokens
|
||||
session.totalTokens += event.totalTokens
|
||||
session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd)
|
||||
mergeLocationBreakdown(session.locationBreakdown, event)
|
||||
mergeModelBreakdown(session.modelBreakdown, event)
|
||||
mergeLocationModelBreakdown(session.locationModelBreakdown, event)
|
||||
|
||||
const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::')
|
||||
const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event)
|
||||
if (!dailyByKey.has(dailyKey)) {
|
||||
dailyByKey.set(dailyKey, daily)
|
||||
}
|
||||
daily.eventCount++
|
||||
daily.inputTokens += event.inputTokens
|
||||
daily.cachedInputTokens += event.cachedInputTokens
|
||||
daily.outputTokens += event.outputTokens
|
||||
daily.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
daily.totalTokens += event.totalTokens
|
||||
daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
||||
left.day === right.day
|
||||
? left.projectLabel.localeCompare(right.projectLabel)
|
||||
: left.day.localeCompare(right.day)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeSessions(sessionsById: Map<string, OpenCodeUsageSession>): OpenCodeUsageSession[] {
|
||||
for (const session of sessionsById.values()) {
|
||||
session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
const primaryLocation = session.locationBreakdown[0] ?? null
|
||||
const primaryModel = session.modelBreakdown[0] ?? null
|
||||
session.primaryProjectLabel =
|
||||
session.locationBreakdown.length <= 1
|
||||
? (primaryLocation?.projectLabel ?? 'Unknown location')
|
||||
: 'Multiple locations'
|
||||
session.hasMixedLocations = session.locationBreakdown.length > 1
|
||||
session.primaryWorktreeId = primaryLocation?.worktreeId ?? null
|
||||
session.primaryRepoId = primaryLocation?.repoId ?? null
|
||||
session.primaryModel =
|
||||
session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models'
|
||||
session.hasMixedModels = session.modelBreakdown.length > 1
|
||||
}
|
||||
|
||||
return [...sessionsById.values()].sort((left, right) =>
|
||||
right.lastTimestamp.localeCompare(left.lastTimestamp)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeSessions(
|
||||
target: Map<string, OpenCodeUsageSession>,
|
||||
sessions: OpenCodeUsageSession[]
|
||||
): void {
|
||||
for (const session of sessions) {
|
||||
const existing = target.get(session.sessionId)
|
||||
if (!existing) {
|
||||
target.set(session.sessionId, structuredClone(session))
|
||||
continue
|
||||
}
|
||||
|
||||
existing.firstTimestamp =
|
||||
session.firstTimestamp < existing.firstTimestamp
|
||||
? session.firstTimestamp
|
||||
: existing.firstTimestamp
|
||||
existing.lastTimestamp =
|
||||
session.lastTimestamp > existing.lastTimestamp
|
||||
? session.lastTimestamp
|
||||
: existing.lastTimestamp
|
||||
existing.eventCount += session.eventCount
|
||||
existing.totalInputTokens += session.totalInputTokens
|
||||
existing.totalCachedInputTokens += session.totalCachedInputTokens
|
||||
existing.totalOutputTokens += session.totalOutputTokens
|
||||
existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens
|
||||
existing.totalTokens += session.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd)
|
||||
|
||||
for (const location of session.locationBreakdown) {
|
||||
const existingLocation =
|
||||
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
|
||||
null
|
||||
if (existingLocation) {
|
||||
existingLocation.eventCount += location.eventCount
|
||||
existingLocation.inputTokens += location.inputTokens
|
||||
existingLocation.cachedInputTokens += location.cachedInputTokens
|
||||
existingLocation.outputTokens += location.outputTokens
|
||||
existingLocation.reasoningOutputTokens += location.reasoningOutputTokens
|
||||
existingLocation.totalTokens += location.totalTokens
|
||||
existingLocation.estimatedCostUsd = addCost(
|
||||
existingLocation.estimatedCostUsd,
|
||||
location.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.locationBreakdown.push({ ...location })
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of session.modelBreakdown) {
|
||||
const existingModel =
|
||||
existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null
|
||||
if (existingModel) {
|
||||
existingModel.eventCount += model.eventCount
|
||||
existingModel.inputTokens += model.inputTokens
|
||||
existingModel.cachedInputTokens += model.cachedInputTokens
|
||||
existingModel.outputTokens += model.outputTokens
|
||||
existingModel.reasoningOutputTokens += model.reasoningOutputTokens
|
||||
existingModel.totalTokens += model.totalTokens
|
||||
existingModel.estimatedCostUsd = addCost(
|
||||
existingModel.estimatedCostUsd,
|
||||
model.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.modelBreakdown.push({ ...model })
|
||||
}
|
||||
}
|
||||
|
||||
for (const locationModel of session.locationModelBreakdown) {
|
||||
const existingLocationModel =
|
||||
existing.locationModelBreakdown.find(
|
||||
(entry) =>
|
||||
entry.locationKey === locationModel.locationKey &&
|
||||
entry.modelKey === locationModel.modelKey
|
||||
) ?? null
|
||||
if (existingLocationModel) {
|
||||
existingLocationModel.eventCount += locationModel.eventCount
|
||||
existingLocationModel.inputTokens += locationModel.inputTokens
|
||||
existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens
|
||||
existingLocationModel.outputTokens += locationModel.outputTokens
|
||||
existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens
|
||||
existingLocationModel.totalTokens += locationModel.totalTokens
|
||||
existingLocationModel.estimatedCostUsd = addCost(
|
||||
existingLocationModel.estimatedCostUsd,
|
||||
locationModel.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.locationModelBreakdown.push({ ...locationModel })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDailyAggregates(
|
||||
target: Map<string, OpenCodeUsageDailyAggregate>,
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
): void {
|
||||
for (const aggregate of dailyAggregates) {
|
||||
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
|
||||
const existing = target.get(key)
|
||||
if (!existing) {
|
||||
target.set(key, { ...aggregate })
|
||||
continue
|
||||
}
|
||||
existing.eventCount += aggregate.eventCount
|
||||
existing.inputTokens += aggregate.inputTokens
|
||||
existing.cachedInputTokens += aggregate.cachedInputTokens
|
||||
existing.outputTokens += aggregate.outputTokens
|
||||
existing.reasoningOutputTokens += aggregate.reasoningOutputTokens
|
||||
existing.totalTokens += aggregate.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd)
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseOpenCodeUsageDatabase(
|
||||
dbPath: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): Promise<OpenCodeUsagePersistedDatabase> {
|
||||
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
db.pragma('query_only = ON')
|
||||
const events: OpenCodeUsageAttributedEvent[] = []
|
||||
for (const row of selectUsageRows(db)) {
|
||||
const parsed = parseOpenCodeUsageRow(row)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...processedDatabase,
|
||||
...aggregateOpenCodeUsage(events)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanOpenCodeUsageDatabases(
|
||||
worktrees: OpenCodeUsageWorktreeRef[],
|
||||
previousProcessedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
): Promise<{
|
||||
processedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
}> {
|
||||
const dbPaths = await listOpenCodeDatabases()
|
||||
const previousByPath = new Map(
|
||||
previousProcessedDatabases.map((database) => [database.path, database])
|
||||
)
|
||||
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
|
||||
for (const [index, dbPath] of dbPaths.entries()) {
|
||||
const databaseInfo = await getProcessedDatabaseInfo(dbPath)
|
||||
const previous = previousByPath.get(dbPath)
|
||||
const canReuse =
|
||||
previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size
|
||||
const processed = canReuse
|
||||
? previous
|
||||
: await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths)
|
||||
|
||||
processedDatabases.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
|
||||
if ((index + 1) % YIELD_EVERY_DATABASES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedDatabases,
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
||||
left.day === right.day
|
||||
? left.projectLabel.localeCompare(right.projectLabel)
|
||||
: left.day.localeCompare(right.day)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeRefs(
|
||||
repos: Repo[],
|
||||
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
|
||||
): OpenCodeUsageWorktreeRef[] {
|
||||
const refs: OpenCodeUsageWorktreeRef[] = []
|
||||
for (const repo of repos) {
|
||||
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
|
||||
refs.push({
|
||||
repoId: repo.id,
|
||||
worktreeId: worktree.worktreeId,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsagePersistedState,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
import { OpenCodeUsageStore, normalizePersistedState } from './store'
|
||||
|
||||
function getDefaultState(): OpenCodeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
worktreeFingerprint: null,
|
||||
processedDatabases: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<OpenCodeUsagePersistedState>): OpenCodeUsageStore {
|
||||
const store = new OpenCodeUsageStore({
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
;(store as unknown as { state: OpenCodeUsagePersistedState }).state = {
|
||||
...getDefaultState(),
|
||||
...state
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<OpenCodeUsageSession> = {}): OpenCodeUsageSession {
|
||||
const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo'
|
||||
const repoId = overrides.primaryRepoId ?? 'repo-1'
|
||||
const projectLabel = overrides.primaryProjectLabel ?? 'Repo'
|
||||
const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5'
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
firstTimestamp: '2026-04-09T10:00:00.000Z',
|
||||
lastTimestamp: '2026-04-09T10:10:00.000Z',
|
||||
primaryModel: model,
|
||||
hasMixedModels: false,
|
||||
primaryProjectLabel: projectLabel,
|
||||
hasMixedLocations: false,
|
||||
primaryWorktreeId: worktreeId,
|
||||
primaryRepoId: repoId,
|
||||
eventCount: 1,
|
||||
totalInputTokens: 1000,
|
||||
totalCachedInputTokens: 400,
|
||||
totalOutputTokens: 250,
|
||||
totalReasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05,
|
||||
locationBreakdown: [
|
||||
{
|
||||
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
projectLabel,
|
||||
repoId,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
modelBreakdown: [
|
||||
{
|
||||
modelKey: model ?? 'unknown',
|
||||
modelLabel: model ?? 'Unknown model',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
locationModelBreakdown: [
|
||||
{
|
||||
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
modelKey: model ?? 'unknown',
|
||||
modelLabel: model ?? 'Unknown model',
|
||||
repoId,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeDaily(
|
||||
overrides: Partial<OpenCodeUsageDailyAggregate> = {}
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo'
|
||||
return {
|
||||
day: '2026-04-09',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
projectLabel: worktreeId ? 'Repo' : 'outside/repo',
|
||||
repoId: worktreeId ? 'repo-1' : null,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenCodeUsageStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
makeSession({
|
||||
primaryProjectLabel: 'outside/repo',
|
||||
primaryWorktreeId: null,
|
||||
primaryRepoId: null
|
||||
})
|
||||
],
|
||||
dailyAggregates: [
|
||||
makeDaily({
|
||||
projectKey: 'cwd:/outside/repo',
|
||||
projectLabel: 'outside/repo',
|
||||
repoId: null,
|
||||
worktreeId: null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
|
||||
expect(summary.hasAnyOpenCodeData).toBe(false)
|
||||
expect(summary.sessions).toBe(0)
|
||||
expect(summary.events).toBe(0)
|
||||
})
|
||||
|
||||
it('uses recorded OpenCode costs and token totals without model pricing inference', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
makeSession({ sessionId: 'session-1' }),
|
||||
makeSession({
|
||||
sessionId: 'session-2',
|
||||
primaryModel: 'openai/gpt-5.5',
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null,
|
||||
modelBreakdown: [
|
||||
{
|
||||
modelKey: 'openai/gpt-5.5',
|
||||
modelLabel: 'openai/gpt-5.5',
|
||||
eventCount: 1,
|
||||
inputTokens: 1500,
|
||||
cachedInputTokens: 200,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
dailyAggregates: [
|
||||
makeDaily(),
|
||||
makeDaily({
|
||||
model: 'openai/gpt-5.5',
|
||||
eventCount: 2,
|
||||
inputTokens: 1500,
|
||||
cachedInputTokens: 200,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
const daily = await store.getDaily('orca', '30d')
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
sessions: 2,
|
||||
events: 3,
|
||||
inputTokens: 2500,
|
||||
cachedInputTokens: 600,
|
||||
outputTokens: 750,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 3350,
|
||||
estimatedCostUsd: 0.05,
|
||||
topModel: 'openai/gpt-5.5',
|
||||
topProject: 'Repo',
|
||||
hasAnyOpenCodeData: true
|
||||
})
|
||||
expect(daily).toEqual([
|
||||
{
|
||||
day: '2026-04-09',
|
||||
inputTokens: 2500,
|
||||
cachedInputTokens: 600,
|
||||
outputTokens: 750,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 3350
|
||||
}
|
||||
])
|
||||
expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({
|
||||
sessions: 1,
|
||||
estimatedCostUsd: null
|
||||
})
|
||||
})
|
||||
|
||||
it('returns recent sessions with OpenCode event and token fields', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
})
|
||||
|
||||
const sessions = await store.getRecentSessions('orca', '30d', 5)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
lastActiveAt: '2026-04-09T10:10:00.000Z',
|
||||
durationMinutes: 10,
|
||||
projectLabel: 'Repo',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
events: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes persisted OpenCode state by schema version', () => {
|
||||
expect(
|
||||
normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
schemaVersion: 0,
|
||||
processedDatabases: [
|
||||
{
|
||||
path: '/tmp/opencode.db',
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
}
|
||||
],
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
})
|
||||
).toEqual(getDefaultState())
|
||||
|
||||
expect(
|
||||
normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
processedDatabases: [
|
||||
{
|
||||
path: '/tmp/opencode.db',
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
}
|
||||
]
|
||||
}).processedDatabases
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,464 @@
|
||||
/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */
|
||||
import { app } from 'electron'
|
||||
import { dirname, join } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageBreakdownRow,
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScanState,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSessionRow,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../shared/opencode-usage-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
const STALE_MS = 5 * 60_000
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function getOpenCodeUsageFile(): string {
|
||||
if (!_openCodeUsageFile) {
|
||||
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
|
||||
}
|
||||
return _openCodeUsageFile
|
||||
}
|
||||
|
||||
function getRangeCutoff(range: OpenCodeUsageRange): string | null {
|
||||
if (range === 'all') {
|
||||
return null
|
||||
}
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
now.setDate(now.getDate() - (days - 1))
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getLocalDay(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 getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
|
||||
const rows = [...worktreesByRepo.entries()]
|
||||
.flatMap(([repoId, worktrees]) =>
|
||||
worktrees.map((worktree) =>
|
||||
JSON.stringify({
|
||||
repoId,
|
||||
worktreeId: worktree.worktreeId,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
return JSON.stringify(rows)
|
||||
}
|
||||
|
||||
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 {
|
||||
private state: OpenCodeUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
this.state = this.load()
|
||||
}
|
||||
|
||||
private load(): OpenCodeUsagePersistedState {
|
||||
try {
|
||||
const usageFile = getOpenCodeUsageFile()
|
||||
if (!existsSync(usageFile)) {
|
||||
return getDefaultState()
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState
|
||||
return normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
...parsed,
|
||||
scanState: {
|
||||
...getDefaultState().scanState,
|
||||
...parsed.scanState
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error)
|
||||
return getDefaultState()
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getOpenCodeUsageFile()
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<OpenCodeUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
getScanState(): OpenCodeUsageScanState {
|
||||
return {
|
||||
...this.state.scanState,
|
||||
isScanning: this.scanPromise !== null,
|
||||
hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(force = false): Promise<OpenCodeUsageScanState> {
|
||||
if (!this.state.scanState.enabled) {
|
||||
return this.getScanState()
|
||||
}
|
||||
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
|
||||
if (!force && this.state.scanState.lastScanCompletedAt) {
|
||||
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
|
||||
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
|
||||
return this.getScanState()
|
||||
}
|
||||
}
|
||||
await this.runScan()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
private async runScan(): Promise<void> {
|
||||
if (this.scanPromise) {
|
||||
await this.scanPromise
|
||||
return
|
||||
}
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
|
||||
const result = await scanOpenCodeUsageDatabases(
|
||||
createWorktreeRefs(repos, worktreesByRepo),
|
||||
this.state.worktreeFingerprint === worktreeFingerprint
|
||||
? this.state.processedDatabases
|
||||
: []
|
||||
)
|
||||
this.state.processedDatabases = result.processedDatabases
|
||||
this.state.sessions = result.sessions
|
||||
this.state.dailyAggregates = result.dailyAggregates
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
this.writeToDisk()
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
await this.scanPromise
|
||||
}
|
||||
|
||||
async getSummary(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): Promise<OpenCodeUsageSummary> {
|
||||
await this.refresh(false)
|
||||
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 {
|
||||
scope,
|
||||
range,
|
||||
sessions: filteredSessions.length,
|
||||
events,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd,
|
||||
topModel,
|
||||
topProject,
|
||||
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
async getDaily(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): Promise<OpenCodeUsageDailyPoint[]> {
|
||||
await this.refresh(false)
|
||||
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))
|
||||
}
|
||||
|
||||
async getBreakdown(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange,
|
||||
kind: OpenCodeUsageBreakdownKind
|
||||
): Promise<OpenCodeUsageBreakdownRow[]> {
|
||||
await this.refresh(false)
|
||||
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)
|
||||
}
|
||||
|
||||
async getRecentSessions(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange,
|
||||
limit = 10
|
||||
): Promise<OpenCodeUsageSessionRow[]> {
|
||||
await this.refresh(false)
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private getFilteredDaily(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange
|
||||
): OpenCodeUsageDailyAggregate[] {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((row) => {
|
||||
if (scope === 'orca' && !row.worktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff && row.day < cutoff) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
return this.state.sessions.filter((session) => {
|
||||
if (scope === 'orca' && !session.primaryWorktreeId) {
|
||||
return false
|
||||
}
|
||||
if (cutoff) {
|
||||
const day = getLocalDay(session.lastTimestamp)
|
||||
if (!day || day < cutoff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private async getCurrentWorktreeFingerprint(): Promise<string> {
|
||||
const repos = this.store.getRepos()
|
||||
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export type OpenCodeUsageProcessedDatabase = {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageLocationBreakdown = {
|
||||
locationKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsageModelBreakdown = {
|
||||
modelKey: string
|
||||
modelLabel: string
|
||||
estimatedCostUsd: number | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageLocationModelBreakdown = {
|
||||
locationKey: string
|
||||
modelKey: string
|
||||
modelLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsageSession = {
|
||||
sessionId: string
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
primaryModel: string | null
|
||||
hasMixedModels: boolean
|
||||
primaryProjectLabel: string
|
||||
hasMixedLocations: boolean
|
||||
primaryWorktreeId: string | null
|
||||
primaryRepoId: string | null
|
||||
eventCount: number
|
||||
totalInputTokens: number
|
||||
totalCachedInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalReasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
locationBreakdown: OpenCodeUsageLocationBreakdown[]
|
||||
modelBreakdown: OpenCodeUsageModelBreakdown[]
|
||||
locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[]
|
||||
}
|
||||
|
||||
export type OpenCodeUsageDailyAggregate = {
|
||||
day: string
|
||||
model: string | null
|
||||
projectKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & {
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
}
|
||||
|
||||
export type OpenCodeUsagePersistedState = {
|
||||
schemaVersion: number
|
||||
worktreeFingerprint: string | null
|
||||
processedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
scanState: {
|
||||
enabled: boolean
|
||||
lastScanStartedAt: number | null
|
||||
lastScanCompletedAt: number | null
|
||||
lastScanError: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodeUsageParsedEvent = {
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
estimatedCostUsd: number | null
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & {
|
||||
day: string
|
||||
projectKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
}
|
||||
@@ -1370,6 +1370,567 @@ describe('Store', () => {
|
||||
expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId)
|
||||
})
|
||||
|
||||
it('hydrates legacy numeric agent status cache through the pane identity migration', async () => {
|
||||
const agentHooksDir = join(testState.dir, 'agent-hooks')
|
||||
mkdirSync(agentHooksDir, { recursive: true })
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: 'pane:1' },
|
||||
activeLeafId: 'pane:1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'pane:1': 'local-pty' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
writeFileSync(
|
||||
join(agentHooksDir, 'last-status.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab1:1': {
|
||||
paneKey: 'tab1:1',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now() - 1000,
|
||||
payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const store = await createStore()
|
||||
const { agentHookServer } = await import('./agent-hooks/server')
|
||||
await agentHookServer.start({ env: 'production', userDataPath: testState.dir })
|
||||
try {
|
||||
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
|
||||
const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null
|
||||
if (leafId === null) {
|
||||
throw new Error('Expected remapped leaf id')
|
||||
}
|
||||
const stablePaneKey = makePaneKey('tab1', leafId)
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: stablePaneKey,
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
state: 'working',
|
||||
prompt: 'legacy numeric prompt',
|
||||
agentType: 'claude'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
agentHookServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => {
|
||||
const agentHooksDir = join(testState.dir, 'agent-hooks')
|
||||
mkdirSync(agentHooksDir, { recursive: true })
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'pane:1' },
|
||||
second: { type: 'leaf', leafId: 'pane:2' },
|
||||
sizes: [50, 50]
|
||||
},
|
||||
activeLeafId: 'pane:1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'pane:1': 'local-pty-1', 'pane:2': 'local-pty-2' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const now = Date.now()
|
||||
writeFileSync(
|
||||
join(agentHooksDir, 'last-status.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab1:1': {
|
||||
paneKey: 'tab1:1',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 2000,
|
||||
payload: { state: 'working', prompt: 'left legacy prompt', agentType: 'claude' }
|
||||
},
|
||||
'tab1:2': {
|
||||
paneKey: 'tab1:2',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 1000,
|
||||
payload: { state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const store = await createStore()
|
||||
const { agentHookServer } = await import('./agent-hooks/server')
|
||||
await agentHookServer.start({ env: 'production', userDataPath: testState.dir })
|
||||
try {
|
||||
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
|
||||
const firstLeafId =
|
||||
layout.root?.type === 'split' && layout.root.first.type === 'leaf'
|
||||
? layout.root.first.leafId
|
||||
: null
|
||||
const secondLeafId =
|
||||
layout.root?.type === 'split' && layout.root.second.type === 'leaf'
|
||||
? layout.root.second.leafId
|
||||
: null
|
||||
if (firstLeafId === null || secondLeafId === null) {
|
||||
throw new Error('Expected remapped split leaves')
|
||||
}
|
||||
const byPaneKey = new Map(
|
||||
agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry])
|
||||
)
|
||||
|
||||
expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'left legacy prompt',
|
||||
agentType: 'claude'
|
||||
})
|
||||
)
|
||||
expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'blocked',
|
||||
prompt: 'right legacy prompt',
|
||||
agentType: 'codex'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
agentHookServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates split-pane legacy status rows even when PTY leaf bindings are absent', async () => {
|
||||
const agentHooksDir = join(testState.dir, 'agent-hooks')
|
||||
mkdirSync(agentHooksDir, { recursive: true })
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'pane:1' },
|
||||
second: { type: 'leaf', leafId: 'pane:2' },
|
||||
sizes: [50, 50]
|
||||
},
|
||||
activeLeafId: 'pane:2',
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const now = Date.now()
|
||||
writeFileSync(
|
||||
join(agentHooksDir, 'last-status.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab1:1': {
|
||||
paneKey: 'tab1:1',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 2000,
|
||||
payload: { state: 'working', prompt: 'left no binding', agentType: 'claude' }
|
||||
},
|
||||
'tab1:2': {
|
||||
paneKey: 'tab1:2',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 1000,
|
||||
payload: { state: 'blocked', prompt: 'right no binding', agentType: 'codex' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const store = await createStore()
|
||||
const { agentHookServer } = await import('./agent-hooks/server')
|
||||
await agentHookServer.start({ env: 'production', userDataPath: testState.dir })
|
||||
try {
|
||||
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
|
||||
const firstLeafId =
|
||||
layout.root?.type === 'split' && layout.root.first.type === 'leaf'
|
||||
? layout.root.first.leafId
|
||||
: null
|
||||
const secondLeafId =
|
||||
layout.root?.type === 'split' && layout.root.second.type === 'leaf'
|
||||
? layout.root.second.leafId
|
||||
: null
|
||||
if (firstLeafId === null || secondLeafId === null) {
|
||||
throw new Error('Expected remapped split leaves')
|
||||
}
|
||||
const byPaneKey = new Map(
|
||||
agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry])
|
||||
)
|
||||
|
||||
expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'left no binding',
|
||||
agentType: 'claude'
|
||||
})
|
||||
)
|
||||
expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'blocked',
|
||||
prompt: 'right no binding',
|
||||
agentType: 'codex'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
agentHookServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists legacy pane-key aliases after the layout has been normalized', async () => {
|
||||
const agentHooksDir = join(testState.dir, 'agent-hooks')
|
||||
mkdirSync(agentHooksDir, { recursive: true })
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: 'pane:1' },
|
||||
activeLeafId: 'pane:1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'pane:1': 'local-pty' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const firstStore = await createStore()
|
||||
const root = firstStore.getWorkspaceSession().terminalLayoutsByTabId.tab1.root
|
||||
const stableLeafId = root?.type === 'leaf' ? root.leafId : null
|
||||
if (stableLeafId === null) {
|
||||
throw new Error('Expected remapped leaf id')
|
||||
}
|
||||
const stablePaneKey = makePaneKey('tab1', stableLeafId)
|
||||
firstStore.flush()
|
||||
|
||||
expect(readDataFile()).toEqual(
|
||||
expect.objectContaining({
|
||||
legacyPaneKeyAliasEntries: [
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:1',
|
||||
stablePaneKey
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const now = Date.now()
|
||||
writeFileSync(
|
||||
join(agentHooksDir, 'last-status.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
'tab1:1': {
|
||||
paneKey: 'tab1:1',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 1000,
|
||||
payload: { state: 'working', prompt: 'post-normalize legacy prompt' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
await createStore()
|
||||
const { agentHookServer } = await import('./agent-hooks/server')
|
||||
await agentHookServer.start({ env: 'production', userDataPath: testState.dir })
|
||||
try {
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: stablePaneKey,
|
||||
state: 'working',
|
||||
prompt: 'post-normalize legacy prompt'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
agentHookServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'pane:1' },
|
||||
second: { type: 'leaf', leafId: 'pane:2' },
|
||||
sizes: [50, 50]
|
||||
},
|
||||
activeLeafId: 'pane:2',
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
|
||||
const firstLeafId =
|
||||
layout.root?.type === 'split' && layout.root.first.type === 'leaf'
|
||||
? layout.root.first.leafId
|
||||
: null
|
||||
const secondLeafId =
|
||||
layout.root?.type === 'split' && layout.root.second.type === 'leaf'
|
||||
? layout.root.second.leafId
|
||||
: null
|
||||
if (
|
||||
!firstLeafId ||
|
||||
!secondLeafId ||
|
||||
!isTerminalLeafId(firstLeafId) ||
|
||||
!isTerminalLeafId(secondLeafId)
|
||||
) {
|
||||
throw new Error('Expected remapped split leaf ids')
|
||||
}
|
||||
const activePaneKey = makePaneKey('tab1', secondLeafId)
|
||||
const firstPaneKey = makePaneKey('tab1', firstLeafId)
|
||||
const secondPaneKey = makePaneKey('tab1', secondLeafId)
|
||||
store.flush()
|
||||
|
||||
expect(readDataFile()).toEqual(
|
||||
expect.objectContaining({
|
||||
legacyPaneKeyAliasEntries: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:0',
|
||||
stablePaneKey: activePaneKey
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:1',
|
||||
stablePaneKey: firstPaneKey
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:2',
|
||||
stablePaneKey: secondPaneKey
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('converts unambiguous dev migration rows into persisted aliases', async () => {
|
||||
const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1)
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'wt1',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'local-pty'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' }
|
||||
}
|
||||
}
|
||||
},
|
||||
migrationUnsupportedPtyEntries: [
|
||||
{
|
||||
ptyId: 'local-pty',
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
paneKey: stablePaneKey,
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local',
|
||||
updatedAt: 123
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.flush()
|
||||
|
||||
expect(readDataFile()).toEqual(
|
||||
expect.objectContaining({
|
||||
migrationUnsupportedPtyEntries: [],
|
||||
legacyPaneKeyAliasEntries: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:0',
|
||||
stablePaneKey
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ptyId: 'local-pty',
|
||||
legacyPaneKey: 'tab1:1',
|
||||
stablePaneKey
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
||||
+218
-70
@@ -37,6 +37,7 @@ import type {
|
||||
OnboardingChecklistState,
|
||||
OnboardingOutcome,
|
||||
OnboardingState,
|
||||
LegacyPaneKeyAliasEntry,
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalTab,
|
||||
@@ -57,11 +58,17 @@ import {
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../shared/stable-pane-id'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
makePaneKey,
|
||||
parseLegacyNumericPaneKey,
|
||||
parsePaneKey
|
||||
} from '../shared/stable-pane-id'
|
||||
import {
|
||||
setMigrationUnsupportedPty,
|
||||
setMigrationUnsupportedPtyPersistenceListener
|
||||
} from './agent-hooks/migration-unsupported-pty-state'
|
||||
import { agentHookServer } from './agent-hooks/server'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
|
||||
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
|
||||
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
||||
@@ -413,64 +420,135 @@ function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): st
|
||||
return undefined
|
||||
}
|
||||
|
||||
type PaneIdentityMigrationEntries = {
|
||||
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
}
|
||||
|
||||
function collectMigrationUnsupportedPtyEntries(args: {
|
||||
session: WorkspaceSessionState
|
||||
tabId: string
|
||||
inputLayout: TerminalLayoutSnapshot
|
||||
normalizedLayout: TerminalLayoutSnapshot
|
||||
leafIdByInputLeafId: Map<string, string>
|
||||
sourceForPtyId: (ptyId: string) => 'local' | 'ssh'
|
||||
}): MigrationUnsupportedPtyEntry[] {
|
||||
const entries: MigrationUnsupportedPtyEntry[] = []
|
||||
}): PaneIdentityMigrationEntries {
|
||||
const worktreeId = findWorktreeIdForTab(args.session, args.tabId)
|
||||
const tab = worktreeId
|
||||
? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId)
|
||||
: undefined
|
||||
const pushEntry = (ptyId: string, leafId: string): void => {
|
||||
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
const registeredLegacyPaneKeys = new Set<string>()
|
||||
const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0
|
||||
const fallbackPtyId =
|
||||
!hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined
|
||||
const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => {
|
||||
if (!isTerminalLeafId(leafId)) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
let paneKey: string
|
||||
try {
|
||||
paneKey = makePaneKey(args.tabId, leafId)
|
||||
} catch {
|
||||
return
|
||||
return false
|
||||
}
|
||||
entries.push({
|
||||
ptyId,
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
tabId: args.tabId,
|
||||
leafId,
|
||||
paneKey,
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: args.sourceForPtyId(ptyId),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1]
|
||||
if (!numeric) {
|
||||
return false
|
||||
}
|
||||
// Why: persisted PaneManager ids are 1-based. A zero-based alias in split
|
||||
// layouts would make tab:1 ambiguous and can route the first pane to the second.
|
||||
const legacyPaneKey = `${args.tabId}:${numeric}`
|
||||
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId)
|
||||
registeredLegacyPaneKeys.add(legacyPaneKey)
|
||||
if (ptyId) {
|
||||
legacyPaneKeyAliasEntries.push({
|
||||
ptyId,
|
||||
legacyPaneKey,
|
||||
stablePaneKey: paneKey,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const [inputLeafId, ptyId] of Object.entries(args.inputLayout.ptyIdsByLeafId ?? {})) {
|
||||
const inputLeafIds = new Set([
|
||||
...collectLayoutLeafIdsInOrder(args.inputLayout.root),
|
||||
...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {})
|
||||
])
|
||||
for (const inputLeafId of inputLeafIds) {
|
||||
if (isTerminalLeafId(inputLeafId)) {
|
||||
continue
|
||||
}
|
||||
const leafId = args.leafIdByInputLeafId.get(inputLeafId)
|
||||
if (leafId) {
|
||||
pushEntry(ptyId, leafId)
|
||||
registerLegacyAlias(
|
||||
inputLeafId,
|
||||
leafId,
|
||||
args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
entries.length === 0 &&
|
||||
tab?.ptyId &&
|
||||
Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length === 0
|
||||
) {
|
||||
if (tab?.ptyId && !hasLeafPtyBindings) {
|
||||
const fallbackLeafId =
|
||||
args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root)
|
||||
if (fallbackLeafId) {
|
||||
// Why: older single-pane sessions can reattach from tab.ptyId only, with
|
||||
// no leaf binding to migrate. Their live shell env can still hold the
|
||||
// legacy pane key, so surface the restart-required row for that PTY.
|
||||
pushEntry(tab.ptyId, fallbackLeafId)
|
||||
if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) {
|
||||
const paneKey = makePaneKey(args.tabId, fallbackLeafId)
|
||||
for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) {
|
||||
if (registeredLegacyPaneKeys.has(legacyPaneKey)) {
|
||||
continue
|
||||
}
|
||||
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId)
|
||||
legacyPaneKeyAliasEntries.push({
|
||||
ptyId: tab.ptyId,
|
||||
legacyPaneKey,
|
||||
stablePaneKey: paneKey,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
// Why: legacy numeric pane keys are now bridged by aliases instead of
|
||||
// persisted as restart-required rows. Existing saved rows are pruned during
|
||||
// normalizePersistedPaneIdentityState.
|
||||
return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries }
|
||||
}
|
||||
|
||||
function legacyMigrationUnsupportedRowsToAliasEntries(
|
||||
entries: MigrationUnsupportedPtyEntry[]
|
||||
): LegacyPaneKeyAliasEntry[] {
|
||||
const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter(
|
||||
(entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey)
|
||||
)
|
||||
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry[]>()
|
||||
for (const entry of normalizedEntries) {
|
||||
const tabId = entry.tabId
|
||||
if (!tabId) {
|
||||
continue
|
||||
}
|
||||
entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry])
|
||||
}
|
||||
const aliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
for (const [tabId, tabEntries] of entriesByTabId) {
|
||||
if (tabEntries.length !== 1) {
|
||||
continue
|
||||
}
|
||||
const [entry] = tabEntries
|
||||
if (!entry.paneKey) {
|
||||
continue
|
||||
}
|
||||
// Why: pre-stable dev/RC migration rows did not store the old numeric
|
||||
// key. Only synthesize the single-pane aliases when the row is unambiguous
|
||||
// for its tab; split rows need layout-derived aliases instead of a guess.
|
||||
for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) {
|
||||
aliasEntries.push({
|
||||
ptyId: entry.ptyId,
|
||||
legacyPaneKey,
|
||||
stablePaneKey: entry.paneKey,
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
return aliasEntries
|
||||
}
|
||||
|
||||
function normalizeTerminalLayoutSnapshotForPersistence(
|
||||
@@ -596,19 +674,20 @@ function normalizeTerminalLayoutSnapshotForPersistence(
|
||||
|
||||
function normalizeWorkspaceSessionPaneIdentities(
|
||||
session: WorkspaceSessionState,
|
||||
priorLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {},
|
||||
sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local'
|
||||
priorLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
|
||||
): {
|
||||
session: WorkspaceSessionState
|
||||
changed: boolean
|
||||
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
|
||||
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
|
||||
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
} {
|
||||
let changed = false
|
||||
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
|
||||
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
|
||||
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
|
||||
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
|
||||
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
|
||||
const normalized = normalizeTerminalLayoutSnapshotForPersistence(
|
||||
@@ -617,16 +696,15 @@ function normalizeWorkspaceSessionPaneIdentities(
|
||||
)
|
||||
terminalLayoutsByTabId[tabId] = normalized.snapshot
|
||||
leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId)
|
||||
migrationUnsupportedEntries.push(
|
||||
...collectMigrationUnsupportedPtyEntries({
|
||||
session,
|
||||
tabId,
|
||||
inputLayout: layout,
|
||||
normalizedLayout: normalized.snapshot,
|
||||
leafIdByInputLeafId: normalized.leafIdByInputLeafId,
|
||||
sourceForPtyId
|
||||
})
|
||||
)
|
||||
const migrationEntries = collectMigrationUnsupportedPtyEntries({
|
||||
session,
|
||||
tabId,
|
||||
inputLayout: layout,
|
||||
normalizedLayout: normalized.snapshot,
|
||||
leafIdByInputLeafId: normalized.leafIdByInputLeafId
|
||||
})
|
||||
migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries)
|
||||
legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries)
|
||||
const leafIdByPtyId = new Map<string, string>()
|
||||
const duplicatePtyIds = new Set<string>()
|
||||
for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) {
|
||||
@@ -648,7 +726,8 @@ function normalizeWorkspaceSessionPaneIdentities(
|
||||
changed,
|
||||
leafIdByInputLeafIdByTabId,
|
||||
leafIdByPtyIdByTabId,
|
||||
migrationUnsupportedEntries
|
||||
migrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,21 +765,19 @@ function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
state: PersistedState
|
||||
changed: boolean
|
||||
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
} {
|
||||
const sshPtyIds = new Set((state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId))
|
||||
const normalizedSession = normalizeWorkspaceSessionPaneIdentities(
|
||||
state.workspaceSession,
|
||||
{},
|
||||
(ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local')
|
||||
)
|
||||
const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {})
|
||||
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
|
||||
state.sshRemotePtyLeases ?? [],
|
||||
normalizedSession.leafIdByInputLeafIdByTabId,
|
||||
normalizedSession.leafIdByPtyIdByTabId
|
||||
)
|
||||
const mergedMigrationUnsupportedEntries = mergeMigrationUnsupportedPtyEntries([
|
||||
...(state.migrationUnsupportedPtyEntries ?? []),
|
||||
...normalizedSession.migrationUnsupportedEntries
|
||||
const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
|
||||
const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([
|
||||
...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries),
|
||||
...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []),
|
||||
...normalizedSession.legacyPaneKeyAliasEntries
|
||||
])
|
||||
const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys(
|
||||
state.ui?.acknowledgedAgentsByPaneKey,
|
||||
@@ -710,16 +787,22 @@ function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
state.migrationUnsupportedPtyEntries ?? [],
|
||||
mergedMigrationUnsupportedEntries
|
||||
)
|
||||
const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual(
|
||||
state.legacyPaneKeyAliasEntries ?? [],
|
||||
mergedLegacyPaneKeyAliasEntries
|
||||
)
|
||||
if (
|
||||
!normalizedSession.changed &&
|
||||
!remappedLeases.changed &&
|
||||
!migrationUnsupportedChanged &&
|
||||
!legacyAliasesChanged &&
|
||||
!remappedAcknowledgements.changed
|
||||
) {
|
||||
return {
|
||||
state,
|
||||
changed: false,
|
||||
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries
|
||||
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -728,6 +811,7 @@ function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
workspaceSession: normalizedSession.session,
|
||||
sshRemotePtyLeases: remappedLeases.leases,
|
||||
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
|
||||
...(remappedAcknowledgements.changed
|
||||
? {
|
||||
ui: {
|
||||
@@ -738,23 +822,11 @@ function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
: {})
|
||||
},
|
||||
changed: true,
|
||||
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries
|
||||
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMigrationUnsupportedPtyEntries(
|
||||
entries: MigrationUnsupportedPtyEntry[]
|
||||
): MigrationUnsupportedPtyEntry[] {
|
||||
const byPtyId = new Map<string, MigrationUnsupportedPtyEntry>()
|
||||
for (const entry of entries) {
|
||||
const existing = byPtyId.get(entry.ptyId)
|
||||
if (!existing || existing.updatedAt <= entry.updatedAt) {
|
||||
byPtyId.set(entry.ptyId, entry)
|
||||
}
|
||||
}
|
||||
return [...byPtyId.values()]
|
||||
}
|
||||
|
||||
function remapAcknowledgedAgentPaneKeys(
|
||||
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
|
||||
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
|
||||
@@ -827,6 +899,57 @@ function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsup
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.filter((entry): entry is LegacyPaneKeyAliasEntry => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = entry as Partial<LegacyPaneKeyAliasEntry>
|
||||
if (
|
||||
typeof candidate.ptyId !== 'string' ||
|
||||
candidate.ptyId.trim().length === 0 ||
|
||||
typeof candidate.legacyPaneKey !== 'string' ||
|
||||
typeof candidate.stablePaneKey !== 'string' ||
|
||||
!Number.isFinite(candidate.updatedAt)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey)
|
||||
const stable = parsePaneKey(candidate.stablePaneKey)
|
||||
return Boolean(legacy && stable && legacy.tabId === stable.tabId)
|
||||
})
|
||||
}
|
||||
|
||||
function mergeLegacyPaneKeyAliasEntries(
|
||||
entries: LegacyPaneKeyAliasEntry[]
|
||||
): LegacyPaneKeyAliasEntry[] {
|
||||
const byLegacyPaneKey = new Map<string, LegacyPaneKeyAliasEntry>()
|
||||
for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) {
|
||||
const existing = byLegacyPaneKey.get(entry.legacyPaneKey)
|
||||
if (!existing || existing.updatedAt <= entry.updatedAt) {
|
||||
byLegacyPaneKey.set(entry.legacyPaneKey, entry)
|
||||
}
|
||||
}
|
||||
return [...byLegacyPaneKey.values()]
|
||||
}
|
||||
|
||||
function legacyPaneKeyAliasEntriesEqual(
|
||||
left: LegacyPaneKeyAliasEntry[],
|
||||
right: LegacyPaneKeyAliasEntry[]
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry]))
|
||||
return left.every((entry) => {
|
||||
const other = rightByLegacyPaneKey.get(entry.legacyPaneKey)
|
||||
return other ? JSON.stringify(entry) === JSON.stringify(other) : false
|
||||
})
|
||||
}
|
||||
|
||||
function migrationUnsupportedEntriesEqual(
|
||||
left: MigrationUnsupportedPtyEntry[],
|
||||
right: MigrationUnsupportedPtyEntry[]
|
||||
@@ -881,10 +1004,23 @@ export class Store {
|
||||
for (const entry of normalized.migrationUnsupportedEntries) {
|
||||
setMigrationUnsupportedPty(entry)
|
||||
}
|
||||
for (const entry of normalized.legacyPaneKeyAliasEntries) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ overwriteExisting: false }
|
||||
)
|
||||
}
|
||||
setMigrationUnsupportedPtyPersistenceListener((entries) => {
|
||||
this.state.migrationUnsupportedPtyEntries = entries
|
||||
this.scheduleSave()
|
||||
})
|
||||
agentHookServer.setPaneKeyAliasPersistenceListener((entries) => {
|
||||
this.state.legacyPaneKeyAliasEntries = entries
|
||||
this.scheduleSave()
|
||||
})
|
||||
if (normalized.changed) {
|
||||
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
|
||||
// the main persistence boundary so older renderer writes cannot revive them.
|
||||
@@ -1164,6 +1300,9 @@ export class Store {
|
||||
migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries(
|
||||
parsed.migrationUnsupportedPtyEntries
|
||||
),
|
||||
legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(
|
||||
parsed.legacyPaneKeyAliasEntries
|
||||
),
|
||||
automations: Array.isArray(parsed.automations) ? parsed.automations : [],
|
||||
automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [],
|
||||
onboarding: (() => {
|
||||
@@ -1722,6 +1861,7 @@ export class Store {
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
dispatchedAt: null,
|
||||
@@ -1744,6 +1884,7 @@ export class Store {
|
||||
status: result.status,
|
||||
workspaceId: result.workspaceId ?? current.workspaceId,
|
||||
terminalSessionId: result.terminalSessionId ?? current.terminalSessionId,
|
||||
usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null),
|
||||
error: result.error ?? null,
|
||||
startedAt: current.startedAt ?? now,
|
||||
dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt
|
||||
@@ -1938,11 +2079,9 @@ export class Store {
|
||||
// the durable binding and re-open the orphan window. Merge in any
|
||||
// existing bindings whenever the incoming snapshot's binding is empty.
|
||||
const prior = this.state.workspaceSession
|
||||
const sshPtyIds = new Set((this.state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId))
|
||||
const normalized = normalizeWorkspaceSessionPaneIdentities(
|
||||
session,
|
||||
prior?.terminalLayoutsByTabId,
|
||||
(ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local')
|
||||
prior?.terminalLayoutsByTabId
|
||||
)
|
||||
for (const entry of normalized.migrationUnsupportedEntries) {
|
||||
setMigrationUnsupportedPty(entry)
|
||||
@@ -1957,6 +2096,15 @@ export class Store {
|
||||
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
|
||||
}
|
||||
}
|
||||
for (const entry of normalized.legacyPaneKeyAliasEntries) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ overwriteExisting: false }
|
||||
)
|
||||
}
|
||||
session = normalized.session
|
||||
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
|
||||
this.state.sshRemotePtyLeases ?? [],
|
||||
|
||||
@@ -211,6 +211,16 @@ import type {
|
||||
CodexUsageSessionRow,
|
||||
CodexUsageSummary
|
||||
} from '../shared/codex-usage-types'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageBreakdownRow,
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScanState,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSessionRow,
|
||||
OpenCodeUsageSummary
|
||||
} from '../shared/opencode-usage-types'
|
||||
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
|
||||
import type {
|
||||
@@ -449,6 +459,30 @@ export type CodexUsageApi = {
|
||||
}) => Promise<CodexUsageSessionRow[]>
|
||||
}
|
||||
|
||||
export type OpenCodeUsageApi = {
|
||||
getScanState: () => Promise<OpenCodeUsageScanState>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<OpenCodeUsageScanState>
|
||||
refresh: (args?: { force?: boolean }) => Promise<OpenCodeUsageScanState>
|
||||
getSummary: (args: {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
}) => Promise<OpenCodeUsageSummary>
|
||||
getDaily: (args: {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
}) => Promise<OpenCodeUsageDailyPoint[]>
|
||||
getBreakdown: (args: {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
kind: OpenCodeUsageBreakdownKind
|
||||
}) => Promise<OpenCodeUsageBreakdownRow[]>
|
||||
getRecentSessions: (args: {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
limit?: number
|
||||
}) => Promise<OpenCodeUsageSessionRow[]>
|
||||
}
|
||||
|
||||
export type AppApi = {
|
||||
/** Returns a URL base for feature-wall assets. In dev this is Vite /@fs;
|
||||
* in packaged builds this is file:// resources. Renderer appends filenames. */
|
||||
@@ -1162,6 +1196,7 @@ export type PreloadApi = {
|
||||
memory: MemoryApi
|
||||
claudeUsage: ClaudeUsageApi
|
||||
codexUsage: CodexUsageApi
|
||||
openCodeUsage: OpenCodeUsageApi
|
||||
fs: {
|
||||
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
|
||||
readFile: (args: {
|
||||
|
||||
@@ -2418,6 +2418,22 @@ const api = {
|
||||
ipcRenderer.invoke('codexUsage:getRecentSessions', args)
|
||||
},
|
||||
|
||||
openCodeUsage: {
|
||||
getScanState: (): Promise<unknown> => ipcRenderer.invoke('openCodeUsage:getScanState'),
|
||||
setEnabled: (args: { enabled: boolean }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:setEnabled', args),
|
||||
refresh: (args?: { force?: boolean }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:refresh', args),
|
||||
getSummary: (args: { scope: string; range: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:getSummary', args),
|
||||
getDaily: (args: { scope: string; range: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:getDaily', args),
|
||||
getBreakdown: (args: { scope: string; range: string; kind: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:getBreakdown', args),
|
||||
getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('openCodeUsage:getRecentSessions', args)
|
||||
},
|
||||
|
||||
runtime: {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeStatus> =>
|
||||
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
|
||||
|
||||
@@ -28,6 +28,7 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset
|
||||
import SmartWorkspaceNameField, {
|
||||
type SmartWorkspaceNameSelection
|
||||
} from '@/components/new-workspace/SmartWorkspaceNameField'
|
||||
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
|
||||
@@ -65,7 +66,7 @@ type NewWorkspaceComposerCardProps = {
|
||||
onSetupDecisionChange: (value: 'run' | 'skip') => void
|
||||
shouldWaitForSetupCheck: boolean
|
||||
resolvedSetupDecision: 'run' | 'skip' | null
|
||||
createError: string | null
|
||||
createError: WorkspaceCreateErrorDisplay | null
|
||||
canUseSparseCheckout: boolean
|
||||
sparsePresets: SparsePreset[]
|
||||
sparseSelectedPresetId: string | null
|
||||
@@ -549,8 +550,19 @@ export default function NewWorkspaceComposerCard({
|
||||
</div>
|
||||
|
||||
{createError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{createError}
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{createError.help ? (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{createError.title}</p>
|
||||
<p>{createError.message}</p>
|
||||
<p className="text-destructive/85">{createError.help}</p>
|
||||
</div>
|
||||
) : (
|
||||
createError.message
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
getAutomationRunStatusLabel,
|
||||
getAutomationRunStatusVariant
|
||||
} from './automation-page-parts'
|
||||
import {
|
||||
formatAutomationCost,
|
||||
formatAutomationTokens,
|
||||
getAutomationUsageStatusLabel,
|
||||
summarizeAutomationRunUsage
|
||||
} from './automation-usage-model'
|
||||
|
||||
type AutomationDetailProps = {
|
||||
automation: Automation | null
|
||||
@@ -129,6 +135,13 @@ export function AutomationDetail({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const usageSummary = summarizeAutomationRunUsage(runs)
|
||||
const usageCoverage =
|
||||
usageSummary.knownRuns > 0
|
||||
? `${usageSummary.knownRuns}/${runs.length} runs`
|
||||
: usageSummary.unavailableRuns > 0
|
||||
? 'Unavailable'
|
||||
: 'No runs'
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
@@ -175,8 +188,7 @@ export function AutomationDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
|
||||
<DetailMetric label="Run location" value={`${projectName} / ${workspaceName}`} />
|
||||
<div className="grid grid-cols-6 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
|
||||
<DetailMetric
|
||||
label="Next run"
|
||||
value={
|
||||
@@ -189,6 +201,12 @@ export function AutomationDetail({
|
||||
label="Last run"
|
||||
value={formatAutomationDateTimeWithRelative(automation.lastRunAt, now)}
|
||||
/>
|
||||
<DetailMetric
|
||||
label="Est. spend"
|
||||
value={formatAutomationCost(usageSummary.estimatedCostUsd)}
|
||||
/>
|
||||
<DetailMetric label="Tokens" value={formatAutomationTokens(usageSummary.totalTokens)} />
|
||||
<DetailMetric label="Usage coverage" value={usageCoverage} />
|
||||
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
|
||||
</div>
|
||||
|
||||
@@ -228,9 +246,11 @@ export function AutomationDetail({
|
||||
<div className="text-sm font-medium">Run history</div>
|
||||
<div className="text-xs text-muted-foreground">{runs.length} runs</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
<div className="grid grid-cols-[minmax(9rem,1fr)_minmax(11rem,1.2fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
<div>Run</div>
|
||||
<div>Workspace</div>
|
||||
<div>Spend</div>
|
||||
<div>Tokens</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
@@ -240,13 +260,16 @@ export function AutomationDetail({
|
||||
? (runWorktree?.displayName ?? 'Missing workspace')
|
||||
: 'Not launched'
|
||||
const rowClassName =
|
||||
'grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors'
|
||||
'grid grid-cols-[minmax(9rem,1fr)_minmax(11rem,1.2fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors'
|
||||
const usageLabel = getAutomationUsageStatusLabel(run.usage)
|
||||
const rowContent = (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<div>{formatAutomationDateTime(run.scheduledFor)}</div>
|
||||
{run.error ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{run.error}</div>
|
||||
{run.error || run.usage?.status === 'unavailable' ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{run.error ?? run.usage?.unavailableMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
@@ -258,6 +281,28 @@ export function AutomationDetail({
|
||||
>
|
||||
{workspaceLabel}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
run.usage?.status === 'known'
|
||||
? 'text-sm tabular-nums'
|
||||
: 'text-sm text-muted-foreground'
|
||||
}
|
||||
title={usageLabel}
|
||||
>
|
||||
{formatAutomationCost(run.usage?.estimatedCostUsd)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
run.usage?.status === 'known'
|
||||
? 'text-sm tabular-nums'
|
||||
: 'text-sm text-muted-foreground'
|
||||
}
|
||||
title={usageLabel}
|
||||
>
|
||||
{run.usage?.status === 'known'
|
||||
? formatAutomationTokens(run.usage.totalTokens)
|
||||
: 'n/a'}
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
<Badge variant={getAutomationRunStatusVariant(run.status)}>
|
||||
{getAutomationRunStatusLabel(run.status)}
|
||||
|
||||
@@ -37,6 +37,11 @@ import type {
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/automation-schedules'
|
||||
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
|
||||
import {
|
||||
formatAutomationCost,
|
||||
formatAutomationTokens,
|
||||
summarizeAutomationRunUsage
|
||||
} from './automation-usage-model'
|
||||
import { AutomationDetail } from './AutomationDetail'
|
||||
import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog'
|
||||
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
|
||||
@@ -739,6 +744,17 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? 'New workspace each run'
|
||||
: (automationWorktree?.displayName ?? 'Missing workspace')
|
||||
const usageSummary = summarizeAutomationRunUsage(
|
||||
runs.filter((run) => run.automationId === automation.id)
|
||||
)
|
||||
const usageText =
|
||||
usageSummary.knownRuns > 0
|
||||
? `${formatAutomationCost(
|
||||
usageSummary.estimatedCostUsd
|
||||
)} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens`
|
||||
: usageSummary.unavailableRuns > 0
|
||||
? 'Usage unavailable'
|
||||
: 'No run usage yet'
|
||||
return (
|
||||
<ContextMenu key={automation.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -774,6 +790,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
)}`
|
||||
: 'Paused'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{usageText}</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
|
||||
@@ -121,6 +121,7 @@ export function ExternalAutomationManagers({
|
||||
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
|
||||
{job.enabled ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
<Badge variant="outline">Usage unsupported</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import {
|
||||
formatAutomationCost,
|
||||
formatAutomationTokens,
|
||||
summarizeAutomationRunUsage
|
||||
} from './automation-usage-model'
|
||||
|
||||
function makeRun(overrides: Partial<AutomationRun>): AutomationRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
automationId: 'automation-1',
|
||||
title: 'Run 1',
|
||||
scheduledFor: 1,
|
||||
status: 'completed',
|
||||
trigger: 'manual',
|
||||
workspaceId: 'wt1',
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: 'tab-1',
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
dispatchedAt: 1,
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('automation usage model', () => {
|
||||
it('rolls known run usage up while preserving unavailable coverage', () => {
|
||||
const summary = summarizeAutomationRunUsage([
|
||||
makeRun({
|
||||
usage: {
|
||||
status: 'known',
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 300,
|
||||
cacheReadTokens: 400,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: 50,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.0042,
|
||||
estimatedCostSource: 'api_equivalent',
|
||||
providerSessionId: 'session-1',
|
||||
attribution: 'provider_session_time_window',
|
||||
collectedAt: 1,
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null
|
||||
}
|
||||
}),
|
||||
makeRun({
|
||||
id: 'run-2',
|
||||
usage: {
|
||||
status: 'unavailable',
|
||||
provider: 'claude',
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt: 2,
|
||||
unavailableReason: 'no_matching_session',
|
||||
unavailableMessage: 'No matching session'
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
expect(summary).toEqual({
|
||||
knownRuns: 1,
|
||||
unavailableRuns: 1,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 300,
|
||||
cacheTokens: 400,
|
||||
reasoningOutputTokens: 50,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.0042
|
||||
})
|
||||
})
|
||||
|
||||
it('formats compact token and cost labels', () => {
|
||||
expect(formatAutomationTokens(1250)).toBe('1.3k')
|
||||
expect(formatAutomationTokens(1_250_000)).toBe('1.3M')
|
||||
expect(formatAutomationCost(null)).toBe('n/a')
|
||||
expect(formatAutomationCost(0.0042)).toBe('$0.0042')
|
||||
expect(formatAutomationCost(1.234)).toBe('$1.23')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations-types'
|
||||
|
||||
export type AutomationUsageSummary = {
|
||||
knownRuns: number
|
||||
unavailableRuns: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export function summarizeAutomationRunUsage(
|
||||
runs: readonly AutomationRun[]
|
||||
): AutomationUsageSummary {
|
||||
let knownRuns = 0
|
||||
let unavailableRuns = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheTokens = 0
|
||||
let reasoningOutputTokens = 0
|
||||
let totalTokens = 0
|
||||
let estimatedCostUsd = 0
|
||||
let hasKnownCost = false
|
||||
|
||||
for (const run of runs) {
|
||||
const usage = run.usage
|
||||
if (!usage) {
|
||||
unavailableRuns++
|
||||
continue
|
||||
}
|
||||
if (usage.status !== 'known') {
|
||||
unavailableRuns++
|
||||
continue
|
||||
}
|
||||
knownRuns++
|
||||
inputTokens += usage.inputTokens ?? 0
|
||||
outputTokens += usage.outputTokens ?? 0
|
||||
cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
||||
reasoningOutputTokens += usage.reasoningOutputTokens ?? 0
|
||||
totalTokens += usage.totalTokens ?? 0
|
||||
if (usage.estimatedCostUsd !== null) {
|
||||
estimatedCostUsd += usage.estimatedCostUsd
|
||||
hasKnownCost = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
knownRuns,
|
||||
unavailableRuns,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAutomationTokens(value: number | null | undefined): string {
|
||||
if (!value) {
|
||||
return '0'
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatAutomationCost(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'n/a'
|
||||
}
|
||||
if (value > 0 && value < 0.01) {
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function getAutomationUsageStatusLabel(
|
||||
usage: AutomationRunUsage | null | undefined
|
||||
): string {
|
||||
if (!usage || usage.status === 'unavailable') {
|
||||
return usage?.unavailableMessage ?? 'Usage unavailable'
|
||||
}
|
||||
const cost = formatAutomationCost(usage.estimatedCostUsd)
|
||||
const tokens = formatAutomationTokens(usage.totalTokens)
|
||||
return `${tokens} tokens · ${cost}`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { getDiffCommentPopoverTop } from './diff-comment-popover-position'
|
||||
|
||||
function makeEditor({
|
||||
lineCount = 10,
|
||||
scrollTop = 15,
|
||||
topForLine = (lineNumber: number) => lineNumber * 20
|
||||
}: {
|
||||
lineCount?: number
|
||||
scrollTop?: number
|
||||
topForLine?: (lineNumber: number) => number
|
||||
} = {}): Parameters<typeof getDiffCommentPopoverTop>[0] {
|
||||
return {
|
||||
getModel: () => ({ getLineCount: () => lineCount }) as monacoEditor.ITextModel,
|
||||
getScrollTop: () => scrollTop,
|
||||
getTopForLineNumber: topForLine
|
||||
}
|
||||
}
|
||||
|
||||
describe('getDiffCommentPopoverTop', () => {
|
||||
it('positions the popover below the anchor line', () => {
|
||||
const top = getDiffCommentPopoverTop(makeEditor(), 3, 20)
|
||||
|
||||
expect(top).toBe(65)
|
||||
})
|
||||
|
||||
it('uses a fallback line height when Monaco does not return a positive number', () => {
|
||||
const top = getDiffCommentPopoverTop(makeEditor(), 3, 0)
|
||||
|
||||
expect(top).toBe(64)
|
||||
})
|
||||
|
||||
it('returns null when the editor has no model', () => {
|
||||
const editor = {
|
||||
...makeEditor(),
|
||||
getModel: () => null
|
||||
}
|
||||
|
||||
expect(getDiffCommentPopoverTop(editor, 3, 20)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for out-of-range line numbers', () => {
|
||||
expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 3, 20)).toBeNull()
|
||||
expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 0, 20)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
|
||||
type DiffCommentPopoverEditor = Pick<
|
||||
monacoEditor.ICodeEditor,
|
||||
'getModel' | 'getScrollTop' | 'getTopForLineNumber'
|
||||
>
|
||||
|
||||
const FALLBACK_LINE_HEIGHT_PX = 19
|
||||
|
||||
export function getDiffCommentPopoverTop(
|
||||
editor: DiffCommentPopoverEditor,
|
||||
lineNumber: number,
|
||||
lineHeight: unknown
|
||||
): number | null {
|
||||
const model = editor.getModel()
|
||||
if (!model) {
|
||||
return null
|
||||
}
|
||||
if (lineNumber < 1 || lineNumber > model.getLineCount()) {
|
||||
return null
|
||||
}
|
||||
const resolvedLineHeight =
|
||||
typeof lineHeight === 'number' && lineHeight > 0 ? lineHeight : FALLBACK_LINE_HEIGHT_PX
|
||||
return editor.getTopForLineNumber(lineNumber) - editor.getScrollTop() + resolvedLineHeight
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { editor as monacoEditor, IDisposable } from 'monaco-editor'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { DiffCommentCard } from './DiffCommentCard'
|
||||
import { getDiffCommentPopoverTop } from './diff-comment-popover-position'
|
||||
|
||||
// Why: Monaco glyph-margin *decorations* don't expose click events in a way
|
||||
// that lets us show a polished popover anchored to a line. So instead we own a
|
||||
@@ -203,7 +204,10 @@ export function useDiffCommentDecorator({
|
||||
}
|
||||
const startLine = Math.min(currentDrag.startLine, currentDrag.endLine)
|
||||
const lineNumber = Math.max(currentDrag.startLine, currentDrag.endLine)
|
||||
const top = editor.getTopForLineNumber(lineNumber) - editor.getScrollTop()
|
||||
const top = getDiffCommentPopoverTop(editor, lineNumber, getLineHeight())
|
||||
if (top == null) {
|
||||
return
|
||||
}
|
||||
onAddCommentClickRef.current({
|
||||
lineNumber,
|
||||
startLine: startLine === lineNumber ? undefined : startLine,
|
||||
|
||||
@@ -16,23 +16,10 @@ import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-gi
|
||||
import '@/lib/monaco-setup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { GitBranchChangeEntry, GitDiffResult, GitStatusEntry } from '../../../../shared/types'
|
||||
import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/types'
|
||||
import { DiffSectionItem } from './DiffSectionItem'
|
||||
import { getCombinedUncommittedEntries } from './combined-diff-entries'
|
||||
|
||||
type DiffSection = {
|
||||
key: string
|
||||
path: string
|
||||
status: string
|
||||
area?: GitStatusEntry['area']
|
||||
oldPath?: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
collapsed: boolean
|
||||
loading: boolean
|
||||
dirty: boolean
|
||||
diffResult: GitDiffResult | null
|
||||
}
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
|
||||
type CachedCombinedDiffViewState = {
|
||||
entrySignature: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: this component owns diff rendering, image previews, comment popovers, and expansion state as one synchronized editor row. */
|
||||
import {
|
||||
lazy,
|
||||
useCallback,
|
||||
@@ -19,27 +20,15 @@ import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position'
|
||||
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
|
||||
import { computeLineStats } from './diff-line-stats'
|
||||
import type { DiffComment, GitDiffResult } from '../../../../shared/types'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { isDiffComment } from '@/lib/diff-comment-compat'
|
||||
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
type DiffSection = {
|
||||
key: string
|
||||
path: string
|
||||
status: string
|
||||
area?: 'staged' | 'unstaged' | 'untracked'
|
||||
oldPath?: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
collapsed: boolean
|
||||
loading: boolean
|
||||
dirty: boolean
|
||||
diffResult: GitDiffResult | null
|
||||
}
|
||||
|
||||
export function DiffSectionItem({
|
||||
section,
|
||||
index,
|
||||
@@ -161,8 +150,15 @@ export function DiffSectionItem({
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const top =
|
||||
modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop()
|
||||
const top = getDiffCommentPopoverTop(
|
||||
modifiedEditor,
|
||||
popover.lineNumber,
|
||||
modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight)
|
||||
)
|
||||
if (top == null) {
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
setPopover((prev) => (prev ? { ...prev, top } : prev))
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
|
||||
@@ -3,12 +3,13 @@ import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import '@/lib/monaco-setup'
|
||||
import { monaco } from '@/lib/monaco-setup'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position'
|
||||
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { isDiffComment } from '@/lib/diff-comment-compat'
|
||||
@@ -129,8 +130,15 @@ export default function DiffViewer({
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const top =
|
||||
modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop()
|
||||
const top = getDiffCommentPopoverTop(
|
||||
modifiedEditor,
|
||||
popover.lineNumber,
|
||||
modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight)
|
||||
)
|
||||
if (top == null) {
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
setPopover((prev) => (prev ? { ...prev, top } : prev))
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
export type DiffSection = {
|
||||
key: string
|
||||
path: string
|
||||
status: string
|
||||
area?: GitStatusEntry['area']
|
||||
oldPath?: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
collapsed: boolean
|
||||
loading: boolean
|
||||
dirty: boolean
|
||||
diffResult: GitDiffResult | null
|
||||
}
|
||||
@@ -232,7 +232,16 @@ export function createRichMarkdownKeyHandler(
|
||||
return false
|
||||
}
|
||||
|
||||
const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current
|
||||
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) {
|
||||
// Why: when the current query has no matching commands the slash menu UI
|
||||
// is already hidden; intercepting further keystrokes would silently
|
||||
// swallow them. Fall through so ProseMirror inserts the character and
|
||||
// syncSlashMenu refreshes the query from the document.
|
||||
if (currentFilteredSlashCommands.length === 0) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu))
|
||||
ctx.setSelectedCommandIndex(0)
|
||||
@@ -246,7 +255,6 @@ export function createRichMarkdownKeyHandler(
|
||||
return true
|
||||
}
|
||||
|
||||
const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current
|
||||
if (currentFilteredSlashCommands.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -491,8 +491,16 @@ function SourceControlInner(): React.JSX.Element {
|
||||
|
||||
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
|
||||
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
|
||||
// Why: when activeRepo.connectionId is truthy, neither the SourceControl
|
||||
// effect below nor WorktreeCard.tsx fetches hostedReview for this branch,
|
||||
// so hostedReviewEntry would stay undefined forever and would permanently
|
||||
// block Publish Branch on SSH-backed worktrees with a linkedPR/linkedGitLabMR
|
||||
// and no upstream. Skip the loading state for those repos so the publish
|
||||
// gate doesn't latch.
|
||||
const isHostedReviewStateLoading =
|
||||
(linkedGitHubPR !== null || linkedGitLabMR !== null) && hostedReviewEntry === undefined
|
||||
!activeRepo?.connectionId &&
|
||||
(linkedGitHubPR !== null || linkedGitLabMR !== null) &&
|
||||
hostedReviewEntry === undefined
|
||||
useEffect(() => {
|
||||
if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') {
|
||||
return
|
||||
|
||||
@@ -570,7 +570,7 @@ function Settings(): React.JSX.Element {
|
||||
{
|
||||
id: 'stats',
|
||||
title: 'Stats & Usage',
|
||||
description: 'Orca stats plus Claude and Codex usage analytics.',
|
||||
description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.',
|
||||
icon: BarChart3,
|
||||
searchEntries: STATS_PANE_SEARCH_ENTRIES
|
||||
},
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
Coins,
|
||||
DatabaseZap,
|
||||
FolderKanban,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScope
|
||||
} from '../../../../shared/opencode-usage-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { StatCard } from './StatCard'
|
||||
|
||||
const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [
|
||||
{ value: 'orca', label: 'Orca worktrees only' },
|
||||
{ value: 'all', label: 'All local OpenCode usage' }
|
||||
]
|
||||
const RANGE_LABELS: Record<OpenCodeUsageRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
all: 'All time'
|
||||
}
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatCost(value: number | null): string {
|
||||
if (value === null) {
|
||||
return 'n/a'
|
||||
}
|
||||
return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatUpdatedAt(timestamp: number | null): string {
|
||||
if (!timestamp) {
|
||||
return 'Not scanned yet'
|
||||
}
|
||||
return `Updated ${new Date(timestamp).toLocaleString()}`
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: string): string {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
const scanState = useAppStore((state) => state.openCodeUsageScanState)
|
||||
const summary = useAppStore((state) => state.openCodeUsageSummary)
|
||||
const daily = useAppStore((state) => state.openCodeUsageDaily)
|
||||
const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown)
|
||||
const projectBreakdown = useAppStore((state) => state.openCodeUsageProjectBreakdown)
|
||||
const recentSessions = useAppStore((state) => state.openCodeUsageRecentSessions)
|
||||
const scope = useAppStore((state) => state.openCodeUsageScope)
|
||||
const range = useAppStore((state) => state.openCodeUsageRange)
|
||||
const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
|
||||
const setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled)
|
||||
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
|
||||
const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope)
|
||||
const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOpenCodeUsage()
|
||||
}, [fetchOpenCodeUsage])
|
||||
|
||||
if (!scanState?.enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reads local OpenCode usage logs to show token, model, and session stats.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label="Enable OpenCode usage analytics"
|
||||
onClick={() => void setOpenCodeUsageEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title="OpenCode Usage Tracking"
|
||||
summaryCardCount={6}
|
||||
summaryGridClassName="md:grid-cols-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatUpdatedAt(scanState.lastScanCompletedAt)}
|
||||
{scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
<DropdownMenu>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="OpenCode usage options">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Filters
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>Scope</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => void setOpenCodeUsageScope(value as OpenCodeUsageScope)}
|
||||
>
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Range</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={range}
|
||||
onValueChange={(value) => void setOpenCodeUsageRange(value as OpenCodeUsageRange)}
|
||||
>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option} value={option}>
|
||||
{RANGE_LABELS[option]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void refreshOpenCodeUsage()}
|
||||
disabled={scanState.isScanning}
|
||||
aria-label="Refresh OpenCode usage"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label="Enable OpenCode usage analytics"
|
||||
onClick={() => void setOpenCodeUsageEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasAnyData ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
No local OpenCode usage found yet for this scope.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<StatCard
|
||||
label="Input tokens"
|
||||
value={formatTokens(summary?.inputTokens ?? 0)}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Output tokens"
|
||||
value={formatTokens(summary?.outputTokens ?? 0)}
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cached input"
|
||||
value={formatTokens(summary?.cachedInputTokens ?? 0)}
|
||||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Reasoning output"
|
||||
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
|
||||
icon={<Brain className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Sessions / Events"
|
||||
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
|
||||
icon={<FolderKanban className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Recorded cost"
|
||||
value={formatCost(summary?.estimatedCostUsd ?? null)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Cost comes from the local OpenCode database when the assistant message recorded one.
|
||||
</p>
|
||||
|
||||
<CodexUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">By model</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Top model: {summary?.topModel ?? 'n/a'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelBreakdown.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} sessions • {row.events} events
|
||||
{row.estimatedCostUsd !== null
|
||||
? ` • ${formatCost(row.estimatedCostUsd)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">By project</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Top project: {summary?.topProject ?? 'n/a'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{projectBreakdown.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} sessions • {row.events} events
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Recent sessions</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Most recent local OpenCode sessions in this scope.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">Last active</th>
|
||||
<th className="px-2 py-2 font-medium">Project</th>
|
||||
<th className="px-2 py-2 font-medium">Model</th>
|
||||
<th className="px-2 py-2 font-medium">Events</th>
|
||||
<th className="px-2 py-2 font-medium">Input</th>
|
||||
<th className="px-2 py-2 font-medium">Output</th>
|
||||
<th className="px-2 py-2 font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentSessions.map((row) => (
|
||||
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.model ?? 'Unknown'}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Bot, Clock, GitPullRequest } from 'lucide-react'
|
||||
import { BarChart3, Bot, Check, ChevronDown, Clock, GitPullRequest } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { StatCard } from './StatCard'
|
||||
import { ClaudeUsagePane } from './ClaudeUsagePane'
|
||||
import { CodexUsagePane } from './CodexUsagePane'
|
||||
import { OpenCodeUsagePane } from './OpenCodeUsagePane'
|
||||
import { UsageOverviewPane } from './UsageOverviewPane'
|
||||
import type { SettingsSearchEntry } from '../settings/settings-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
|
||||
export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Stats & Usage',
|
||||
description:
|
||||
'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.',
|
||||
'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.',
|
||||
keywords: [
|
||||
'stats',
|
||||
'usage',
|
||||
@@ -23,6 +31,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
'tracking',
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'tokens',
|
||||
'cache'
|
||||
]
|
||||
@@ -57,10 +66,29 @@ function formatTrackingSince(timestamp: number | null): string {
|
||||
return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`
|
||||
}
|
||||
|
||||
type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode'
|
||||
|
||||
const USAGE_ANALYTICS_OPTIONS = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'opencode', label: 'OpenCode' }
|
||||
] as const satisfies readonly { id: UsageTab; label: string }[]
|
||||
|
||||
function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element {
|
||||
if (tab === 'overview') {
|
||||
return <BarChart3 className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
return <AgentIcon agent={tab} size={14} />
|
||||
}
|
||||
|
||||
export function StatsPane(): React.JSX.Element {
|
||||
const summary = useAppStore((s) => s.statsSummary)
|
||||
const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary)
|
||||
const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview')
|
||||
const [activeUsageTab, setActiveUsageTab] = useState<UsageTab>('overview')
|
||||
const activeUsageOption =
|
||||
USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ??
|
||||
USAGE_ANALYTICS_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatsSummary()
|
||||
@@ -106,28 +134,40 @@ export function StatsPane(): React.JSX.Element {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Usage Analytics</h3>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Usage analytics provider"
|
||||
className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
|
||||
>
|
||||
{(['overview', 'claude', 'codex'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
aria-pressed={activeUsageTab === tab}
|
||||
onClick={() => setActiveUsageTab(tab)}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center justify-center rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all',
|
||||
activeUsageTab === tab
|
||||
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
|
||||
: 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="usage-provider-select"
|
||||
aria-label={`Usage analytics provider: ${activeUsageOption.label}`}
|
||||
className="min-w-36 justify-between"
|
||||
>
|
||||
{tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<UsageAnalyticsOptionIcon tab={activeUsageOption.id} />
|
||||
<span className="truncate">{activeUsageOption.label}</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-1 size-3.5 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{USAGE_ANALYTICS_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onSelect={() => setActiveUsageTab(option.id)}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<UsageAnalyticsOptionIcon tab={option.id} />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</span>
|
||||
<Check
|
||||
className={`ml-auto size-3.5 ${
|
||||
activeUsageTab === option.id ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the
|
||||
@@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element {
|
||||
<UsageOverviewPane />
|
||||
) : activeUsageTab === 'claude' ? (
|
||||
<ClaudeUsagePane />
|
||||
) : (
|
||||
) : activeUsageTab === 'codex' ? (
|
||||
<CodexUsagePane />
|
||||
) : (
|
||||
<OpenCodeUsagePane />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the
|
||||
provider fetch wiring so the combined usage surface stays easy to audit. */
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
@@ -143,7 +145,7 @@ function DailyIntensityGrid({
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent combined Claude and Codex token activity.
|
||||
Recent combined Claude, Codex, and OpenCode token activity.
|
||||
</p>
|
||||
</div>
|
||||
{bestDay && bestDay.totalTokens > 0 ? (
|
||||
@@ -248,17 +250,24 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
const codexScanState = useAppStore((state) => state.codexUsageScanState)
|
||||
const codexSummary = useAppStore((state) => state.codexUsageSummary)
|
||||
const codexDaily = useAppStore((state) => state.codexUsageDaily)
|
||||
const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState)
|
||||
const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary)
|
||||
const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily)
|
||||
const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
|
||||
const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
|
||||
const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
|
||||
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
|
||||
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
|
||||
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
|
||||
const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage)
|
||||
const enableCodexUsage = useAppStore((state) => state.enableCodexUsage)
|
||||
const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchClaudeUsage()
|
||||
void fetchCodexUsage()
|
||||
}, [fetchClaudeUsage, fetchCodexUsage])
|
||||
void fetchOpenCodeUsage()
|
||||
}, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage])
|
||||
|
||||
const overview = useMemo(
|
||||
() =>
|
||||
@@ -272,9 +281,24 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
scanState: codexScanState,
|
||||
summary: codexSummary,
|
||||
daily: codexDaily
|
||||
},
|
||||
opencode: {
|
||||
scanState: openCodeScanState,
|
||||
summary: openCodeSummary,
|
||||
daily: openCodeDaily
|
||||
}
|
||||
}),
|
||||
[claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary]
|
||||
[
|
||||
claudeDaily,
|
||||
claudeScanState,
|
||||
claudeSummary,
|
||||
codexDaily,
|
||||
codexScanState,
|
||||
codexSummary,
|
||||
openCodeDaily,
|
||||
openCodeScanState,
|
||||
openCodeSummary
|
||||
]
|
||||
)
|
||||
const recentDays = useMemo(
|
||||
() => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT),
|
||||
@@ -285,7 +309,8 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
const handleRefresh = (): void => {
|
||||
void Promise.all([
|
||||
claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(),
|
||||
codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve()
|
||||
codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(),
|
||||
openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -334,6 +359,9 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
|
||||
Enable Codex
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void enableOpenCodeUsage()}>
|
||||
Enable OpenCode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
|
||||
{!overview.hasAnyData ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
|
||||
No local Claude or Codex usage found yet. The overview will populate after the next
|
||||
agent session writes token logs.
|
||||
No local Claude, Codex, or OpenCode usage found yet. The overview will populate
|
||||
after the next agent session writes token logs.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
@@ -399,8 +427,10 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
onEnable={() => {
|
||||
if (provider.id === 'claude') {
|
||||
void enableClaudeUsage()
|
||||
} else {
|
||||
} else if (provider.id === 'codex') {
|
||||
void enableCodexUsage()
|
||||
} else {
|
||||
void enableOpenCodeUsage()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
CodexUsageScanState,
|
||||
CodexUsageSummary
|
||||
} from '../../../../shared/codex-usage-types'
|
||||
import type {
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageScanState,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../../../shared/opencode-usage-types'
|
||||
import {
|
||||
buildUsageOverview,
|
||||
formatUsageCost,
|
||||
@@ -38,8 +43,19 @@ function enabledCodexScanState(): CodexUsageScanState {
|
||||
}
|
||||
}
|
||||
|
||||
function enabledOpenCodeScanState(): OpenCodeUsageScanState {
|
||||
return {
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: 500,
|
||||
lastScanCompletedAt: 600,
|
||||
lastScanError: null,
|
||||
hasAnyOpenCodeData: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('usage overview model', () => {
|
||||
it('combines Claude and Codex totals without double-counting Codex cached input', () => {
|
||||
it('combines provider totals without double-counting cached input', () => {
|
||||
const claudeSummary: ClaudeUsageSummary = {
|
||||
scope: 'orca',
|
||||
range: '30d',
|
||||
@@ -71,6 +87,21 @@ describe('usage overview model', () => {
|
||||
topProject: 'orca-secondary',
|
||||
hasAnyCodexData: true
|
||||
}
|
||||
const openCodeSummary: OpenCodeUsageSummary = {
|
||||
scope: 'orca',
|
||||
range: '30d',
|
||||
sessions: 1,
|
||||
events: 2,
|
||||
inputTokens: 1_000,
|
||||
cachedInputTokens: 250,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1_600,
|
||||
estimatedCostUsd: 0.03,
|
||||
topModel: 'anthropic/claude-sonnet-4-5',
|
||||
topProject: 'orca-third',
|
||||
hasAnyOpenCodeData: true
|
||||
}
|
||||
const claudeDaily: ClaudeUsageDailyPoint[] = [
|
||||
{
|
||||
day: '2026-05-13',
|
||||
@@ -105,6 +136,16 @@ describe('usage overview model', () => {
|
||||
totalTokens: 1_200
|
||||
}
|
||||
]
|
||||
const openCodeDaily: OpenCodeUsageDailyPoint[] = [
|
||||
{
|
||||
day: '2026-05-15',
|
||||
inputTokens: 1_000,
|
||||
cachedInputTokens: 250,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1_600
|
||||
}
|
||||
]
|
||||
|
||||
const overview = buildUsageOverview({
|
||||
claude: {
|
||||
@@ -116,24 +157,30 @@ describe('usage overview model', () => {
|
||||
scanState: enabledCodexScanState(),
|
||||
summary: codexSummary,
|
||||
daily: codexDaily
|
||||
},
|
||||
opencode: {
|
||||
scanState: enabledOpenCodeScanState(),
|
||||
summary: openCodeSummary,
|
||||
daily: openCodeDaily
|
||||
}
|
||||
})
|
||||
|
||||
expect(overview.totalTokens).toBe(9_200)
|
||||
expect(overview.newInputTokens).toBe(2_200)
|
||||
expect(overview.cacheTokens).toBe(5_300)
|
||||
expect(overview.outputTokens).toBe(1_700)
|
||||
expect(overview.reasoningTokens).toBe(300)
|
||||
expect(overview.sessions).toBe(3)
|
||||
expect(overview.activityCount).toBe(7)
|
||||
expect(overview.totalTokens).toBe(10_800)
|
||||
expect(overview.newInputTokens).toBe(2_950)
|
||||
expect(overview.cacheTokens).toBe(5_550)
|
||||
expect(overview.outputTokens).toBe(2_200)
|
||||
expect(overview.reasoningTokens).toBe(400)
|
||||
expect(overview.sessions).toBe(4)
|
||||
expect(overview.activityCount).toBe(9)
|
||||
expect(overview.activeDays).toBe(3)
|
||||
expect(overview.estimatedCostUsd).toBeCloseTo(0.06)
|
||||
expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500)
|
||||
expect(overview.estimatedCostUsd).toBeCloseTo(0.09)
|
||||
expect(overview.cacheShare).toBeCloseTo(5_550 / 8_500)
|
||||
expect(overview.bestDay).toMatchObject({
|
||||
day: '2026-05-14',
|
||||
totalTokens: 4_500,
|
||||
claudeTokens: 2_500,
|
||||
codexTokens: 2_000,
|
||||
openCodeTokens: 0,
|
||||
intensity: 4
|
||||
})
|
||||
expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({
|
||||
@@ -141,6 +188,11 @@ describe('usage overview model', () => {
|
||||
cacheTokens: 800,
|
||||
totalTokens: 3_200
|
||||
})
|
||||
expect(overview.providers.find((provider) => provider.id === 'opencode')).toMatchObject({
|
||||
newInputTokens: 750,
|
||||
cacheTokens: 250,
|
||||
totalTokens: 1_600
|
||||
})
|
||||
})
|
||||
|
||||
it('pads recent usage days with zero-token cells', () => {
|
||||
@@ -151,6 +203,7 @@ describe('usage overview model', () => {
|
||||
totalTokens: 4_500,
|
||||
claudeTokens: 2_500,
|
||||
codexTokens: 2_000,
|
||||
openCodeTokens: 0,
|
||||
intensity: 4
|
||||
}
|
||||
],
|
||||
@@ -164,6 +217,7 @@ describe('usage overview model', () => {
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0,
|
||||
intensity: 0
|
||||
},
|
||||
{
|
||||
@@ -171,6 +225,7 @@ describe('usage overview model', () => {
|
||||
totalTokens: 4_500,
|
||||
claudeTokens: 2_500,
|
||||
codexTokens: 2_000,
|
||||
openCodeTokens: 0,
|
||||
intensity: 4
|
||||
},
|
||||
{
|
||||
@@ -178,6 +233,7 @@ describe('usage overview model', () => {
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0,
|
||||
intensity: 0
|
||||
}
|
||||
])
|
||||
@@ -186,7 +242,8 @@ describe('usage overview model', () => {
|
||||
it('reports disabled providers as an empty overview', () => {
|
||||
const overview = buildUsageOverview({
|
||||
claude: { scanState: null, summary: null, daily: [] },
|
||||
codex: { scanState: null, summary: null, daily: [] }
|
||||
codex: { scanState: null, summary: null, daily: [] },
|
||||
opencode: { scanState: null, summary: null, daily: [] }
|
||||
})
|
||||
|
||||
expect(overview.hasAnyEnabledProvider).toBe(false)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share
|
||||
one tested model so the overview UI cannot drift from the math. */
|
||||
import type {
|
||||
ClaudeUsageDailyPoint,
|
||||
ClaudeUsageScanState,
|
||||
@@ -8,8 +10,13 @@ import type {
|
||||
CodexUsageScanState,
|
||||
CodexUsageSummary
|
||||
} from '../../../../shared/codex-usage-types'
|
||||
import type {
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageScanState,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../../../shared/opencode-usage-types'
|
||||
|
||||
export type UsageProviderId = 'claude' | 'codex'
|
||||
export type UsageProviderId = 'claude' | 'codex' | 'opencode'
|
||||
|
||||
export type UsageProviderOverview = {
|
||||
id: UsageProviderId
|
||||
@@ -38,6 +45,7 @@ export type UsageOverviewDailyPoint = {
|
||||
totalTokens: number
|
||||
claudeTokens: number
|
||||
codexTokens: number
|
||||
openCodeTokens: number
|
||||
intensity: 0 | 1 | 2 | 3 | 4
|
||||
}
|
||||
|
||||
@@ -74,6 +82,11 @@ export type UsageOverviewInput = {
|
||||
summary: CodexUsageSummary | null
|
||||
daily: CodexUsageDailyPoint[]
|
||||
}
|
||||
opencode: {
|
||||
scanState: OpenCodeUsageScanState | null
|
||||
summary: OpenCodeUsageSummary | null
|
||||
daily: OpenCodeUsageDailyPoint[]
|
||||
}
|
||||
}
|
||||
|
||||
function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number {
|
||||
@@ -87,6 +100,13 @@ function getCodexNewInputTokens(summary: CodexUsageSummary | null): number {
|
||||
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
|
||||
}
|
||||
|
||||
function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number {
|
||||
if (!summary) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
|
||||
}
|
||||
|
||||
function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (totalTokens <= 0 || maxTokens <= 0) {
|
||||
return 0
|
||||
@@ -169,6 +189,34 @@ function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderO
|
||||
}
|
||||
}
|
||||
|
||||
function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview {
|
||||
const summary = input.summary
|
||||
const dailyActiveDays = input.daily
|
||||
.filter((entry) => entry.totalTokens > 0)
|
||||
.map((entry) => entry.day)
|
||||
return {
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
enabled: input.scanState?.enabled ?? false,
|
||||
isScanning: input.scanState?.isScanning ?? false,
|
||||
hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false,
|
||||
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
|
||||
lastScanError: input.scanState?.lastScanError ?? null,
|
||||
sessions: summary?.sessions ?? 0,
|
||||
activityLabel: 'events',
|
||||
activityCount: summary?.events ?? 0,
|
||||
totalTokens: summary?.totalTokens ?? 0,
|
||||
newInputTokens: getOpenCodeNewInputTokens(summary),
|
||||
outputTokens: summary?.outputTokens ?? 0,
|
||||
cacheTokens: summary?.cachedInputTokens ?? 0,
|
||||
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
|
||||
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
|
||||
topModel: summary?.topModel ?? null,
|
||||
topProject: summary?.topProject ?? null,
|
||||
activeDays: countActiveDays(dailyActiveDays)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] {
|
||||
const byDay = new Map<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
|
||||
|
||||
@@ -177,7 +225,8 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[
|
||||
day: entry.day,
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0
|
||||
}
|
||||
const total = getClaudeDailyTotal(entry)
|
||||
current.totalTokens += total
|
||||
@@ -190,13 +239,27 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[
|
||||
day: entry.day,
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0
|
||||
}
|
||||
current.totalTokens += entry.totalTokens
|
||||
current.codexTokens += entry.totalTokens
|
||||
byDay.set(entry.day, current)
|
||||
}
|
||||
|
||||
for (const entry of input.opencode.daily) {
|
||||
const current = byDay.get(entry.day) ?? {
|
||||
day: entry.day,
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0
|
||||
}
|
||||
current.totalTokens += entry.totalTokens
|
||||
current.openCodeTokens += entry.totalTokens
|
||||
byDay.set(entry.day, current)
|
||||
}
|
||||
|
||||
const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens))
|
||||
return [...byDay.values()]
|
||||
.sort((left, right) => left.day.localeCompare(right.day))
|
||||
@@ -234,6 +297,7 @@ export function getRecentUsageDays(
|
||||
totalTokens: 0,
|
||||
claudeTokens: 0,
|
||||
codexTokens: 0,
|
||||
openCodeTokens: 0,
|
||||
intensity: 0
|
||||
}
|
||||
)
|
||||
@@ -242,7 +306,11 @@ export function getRecentUsageDays(
|
||||
}
|
||||
|
||||
export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel {
|
||||
const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)]
|
||||
const providers = [
|
||||
createClaudeProvider(input.claude),
|
||||
createCodexProvider(input.codex),
|
||||
createOpenCodeProvider(input.opencode)
|
||||
]
|
||||
const daily = buildDailyOverview(input)
|
||||
const bestDay =
|
||||
daily.length === 0
|
||||
|
||||
@@ -61,6 +61,11 @@ import {
|
||||
readRuntimeIssueCommand,
|
||||
type HookCheckResult
|
||||
} from '@/runtime/runtime-hooks-client'
|
||||
import {
|
||||
formatWorkspaceCreateError,
|
||||
getWorkspaceCreateErrorToastMessage,
|
||||
type WorkspaceCreateErrorDisplay
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
|
||||
export type UseComposerStateOptions = {
|
||||
initialRepoId?: string
|
||||
@@ -167,7 +172,7 @@ export type ComposerCardProps = {
|
||||
onSetupDecisionChange: (value: 'run' | 'skip') => void
|
||||
shouldWaitForSetupCheck: boolean
|
||||
resolvedSetupDecision: 'run' | 'skip' | null
|
||||
createError: string | null
|
||||
createError: WorkspaceCreateErrorDisplay | null
|
||||
canUseSparseCheckout: boolean
|
||||
/** Saved presets for the currently-selected repo. Empty array when no
|
||||
* presets exist or when the repo is remote. */
|
||||
@@ -375,7 +380,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
|
||||
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<WorkspaceCreateErrorDisplay | null>(null)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(
|
||||
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
|
||||
)
|
||||
@@ -1664,9 +1669,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
}
|
||||
onCreated?.()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
|
||||
setCreateError(message)
|
||||
toast.error(message)
|
||||
const formattedError = formatWorkspaceCreateError(error)
|
||||
setCreateError(formattedError)
|
||||
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -1896,9 +1901,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
}
|
||||
onCreated?.()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
|
||||
setCreateError(message)
|
||||
toast.error(message)
|
||||
const formattedError = formatWorkspaceCreateError(error)
|
||||
setCreateError(formattedError)
|
||||
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatWorkspaceCreateError,
|
||||
getWorkspaceCreateErrorToastMessage
|
||||
} from './workspace-create-error-format'
|
||||
|
||||
describe('formatWorkspaceCreateError', () => {
|
||||
it('returns guidance for missing default base ref failures', () => {
|
||||
const error = new Error(
|
||||
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
|
||||
)
|
||||
|
||||
const formatted = formatWorkspaceCreateError(error)
|
||||
|
||||
expect(formatted).toEqual({
|
||||
title: 'No base branch found',
|
||||
message: 'Orca could not resolve a usable base ref for this workspace.',
|
||||
help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.'
|
||||
})
|
||||
expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('No base branch found')
|
||||
})
|
||||
|
||||
it('matches missing base ref failures case-insensitively', () => {
|
||||
const formatted = formatWorkspaceCreateError(
|
||||
new Error('COULD NOT RESOLVE A DEFAULT BASE REF from remote provider')
|
||||
)
|
||||
|
||||
expect(formatted.title).toBe('No base branch found')
|
||||
expect(formatted.help).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes unknown errors through unchanged', () => {
|
||||
const formatted = formatWorkspaceCreateError(new Error('fatal: not a git repository'))
|
||||
|
||||
expect(formatted).toEqual({
|
||||
title: 'fatal: not a git repository',
|
||||
message: 'fatal: not a git repository'
|
||||
})
|
||||
expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('fatal: not a git repository')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
export type WorkspaceCreateErrorDisplay = {
|
||||
title: string
|
||||
message: string
|
||||
help?: string
|
||||
}
|
||||
|
||||
const MISSING_BASE_REF_ANCHOR = 'could not resolve a default base ref'
|
||||
|
||||
export function formatWorkspaceCreateError(error: unknown): WorkspaceCreateErrorDisplay {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
|
||||
|
||||
if (message.toLowerCase().includes(MISSING_BASE_REF_ANCHOR)) {
|
||||
return {
|
||||
title: 'No base branch found',
|
||||
message: 'Orca could not resolve a usable base ref for this workspace.',
|
||||
help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: message,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCreateErrorToastMessage(error: WorkspaceCreateErrorDisplay): string {
|
||||
return error.help ? error.title : error.message
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { createMemorySlice } from './slices/memory'
|
||||
import { createWorkspaceSpaceSlice } from './slices/workspace-space'
|
||||
import { createClaudeUsageSlice } from './slices/claude-usage'
|
||||
import { createCodexUsageSlice } from './slices/codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './slices/opencode-usage'
|
||||
import { createBrowserSlice } from './slices/browser'
|
||||
import { createRateLimitSlice } from './slices/rate-limits'
|
||||
import { createSshSlice } from './slices/ssh'
|
||||
@@ -45,6 +46,7 @@ export const useAppStore = create<AppState>()((...a) => ({
|
||||
...createWorkspaceSpaceSlice(...a),
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createCodexUsageSlice(...a),
|
||||
...createOpenCodeUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a),
|
||||
|
||||
@@ -81,6 +81,22 @@ const mockApi = {
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
openCodeUsage: {
|
||||
getScanState: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null,
|
||||
hasAnyOpenCodeData: false
|
||||
}),
|
||||
setEnabled: vi.fn().mockResolvedValue({}),
|
||||
refresh: vi.fn().mockResolvedValue({}),
|
||||
getSummary: vi.fn().mockResolvedValue(null),
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +119,7 @@ import { createMemorySlice } from './memory'
|
||||
import { createWorkspaceSpaceSlice } from './workspace-space'
|
||||
import { createClaudeUsageSlice } from './claude-usage'
|
||||
import { createCodexUsageSlice } from './codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './opencode-usage'
|
||||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
@@ -131,6 +148,7 @@ function createTestStore() {
|
||||
...createWorkspaceSpaceSlice(...a),
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createCodexUsageSlice(...a),
|
||||
...createOpenCodeUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownRow,
|
||||
OpenCodeUsageDailyPoint,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScanState,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSessionRow,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../../../shared/opencode-usage-types'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
export type OpenCodeUsageSlice = {
|
||||
openCodeUsageScope: OpenCodeUsageScope
|
||||
openCodeUsageRange: OpenCodeUsageRange
|
||||
openCodeUsageScanState: OpenCodeUsageScanState | null
|
||||
openCodeUsageSummary: OpenCodeUsageSummary | null
|
||||
openCodeUsageDaily: OpenCodeUsageDailyPoint[]
|
||||
openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[]
|
||||
openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[]
|
||||
openCodeUsageRecentSessions: OpenCodeUsageSessionRow[]
|
||||
setOpenCodeUsageEnabled: (enabled: boolean) => Promise<void>
|
||||
setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise<void>
|
||||
setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise<void>
|
||||
fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise<void>
|
||||
enableOpenCodeUsage: () => Promise<void>
|
||||
refreshOpenCodeUsage: () => Promise<void>
|
||||
}
|
||||
|
||||
export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUsageSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
openCodeUsageScope: 'orca',
|
||||
openCodeUsageRange: '30d',
|
||||
openCodeUsageScanState: null,
|
||||
openCodeUsageSummary: null,
|
||||
openCodeUsageDaily: [],
|
||||
openCodeUsageModelBreakdown: [],
|
||||
openCodeUsageProjectBreakdown: [],
|
||||
openCodeUsageRecentSessions: [],
|
||||
|
||||
setOpenCodeUsageEnabled: async (enabled) => {
|
||||
try {
|
||||
const nextScanState = (await window.api.openCodeUsage.setEnabled({
|
||||
enabled
|
||||
})) as OpenCodeUsageScanState
|
||||
set({
|
||||
openCodeUsageScanState: enabled
|
||||
? {
|
||||
...nextScanState,
|
||||
isScanning: true,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
: nextScanState,
|
||||
openCodeUsageSummary: null,
|
||||
openCodeUsageDaily: [],
|
||||
openCodeUsageModelBreakdown: [],
|
||||
openCodeUsageProjectBreakdown: [],
|
||||
openCodeUsageRecentSessions: []
|
||||
})
|
||||
if (enabled) {
|
||||
await get().fetchOpenCodeUsage({ forceRefresh: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OpenCode usage setting:', error)
|
||||
}
|
||||
},
|
||||
|
||||
setOpenCodeUsageScope: async (scope) => {
|
||||
set({ openCodeUsageScope: scope })
|
||||
await get().fetchOpenCodeUsage()
|
||||
},
|
||||
|
||||
setOpenCodeUsageRange: async (range) => {
|
||||
set({ openCodeUsageRange: range })
|
||||
await get().fetchOpenCodeUsage()
|
||||
},
|
||||
|
||||
fetchOpenCodeUsage: async (opts) => {
|
||||
try {
|
||||
const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState
|
||||
const currentScanState = get().openCodeUsageScanState
|
||||
const shouldPreserveLoadingState =
|
||||
opts?.forceRefresh === true &&
|
||||
currentScanState?.enabled === true &&
|
||||
get().openCodeUsageSummary === null
|
||||
set({
|
||||
openCodeUsageScanState: shouldPreserveLoadingState
|
||||
? {
|
||||
...scanState,
|
||||
isScanning: true,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
: scanState
|
||||
})
|
||||
if (!scanState.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextScanState = (await window.api.openCodeUsage.refresh({
|
||||
force: opts?.forceRefresh ?? false
|
||||
})) as OpenCodeUsageScanState
|
||||
const { openCodeUsageScope, openCodeUsageRange } = get()
|
||||
|
||||
const [summary, daily, modelBreakdown, projectBreakdown, recentSessions] = await Promise.all([
|
||||
window.api.openCodeUsage.getSummary({
|
||||
scope: openCodeUsageScope,
|
||||
range: openCodeUsageRange
|
||||
}) as Promise<OpenCodeUsageSummary>,
|
||||
window.api.openCodeUsage.getDaily({
|
||||
scope: openCodeUsageScope,
|
||||
range: openCodeUsageRange
|
||||
}) as Promise<OpenCodeUsageDailyPoint[]>,
|
||||
window.api.openCodeUsage.getBreakdown({
|
||||
scope: openCodeUsageScope,
|
||||
range: openCodeUsageRange,
|
||||
kind: 'model'
|
||||
}) as Promise<OpenCodeUsageBreakdownRow[]>,
|
||||
window.api.openCodeUsage.getBreakdown({
|
||||
scope: openCodeUsageScope,
|
||||
range: openCodeUsageRange,
|
||||
kind: 'project'
|
||||
}) as Promise<OpenCodeUsageBreakdownRow[]>,
|
||||
window.api.openCodeUsage.getRecentSessions({
|
||||
scope: openCodeUsageScope,
|
||||
range: openCodeUsageRange,
|
||||
limit: 10
|
||||
}) as Promise<OpenCodeUsageSessionRow[]>
|
||||
])
|
||||
|
||||
set({
|
||||
openCodeUsageScanState: nextScanState,
|
||||
openCodeUsageSummary: summary,
|
||||
openCodeUsageDaily: daily,
|
||||
openCodeUsageModelBreakdown: modelBreakdown,
|
||||
openCodeUsageProjectBreakdown: projectBreakdown,
|
||||
openCodeUsageRecentSessions: recentSessions
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OpenCode usage:', error)
|
||||
}
|
||||
},
|
||||
|
||||
enableOpenCodeUsage: async () => {
|
||||
await get().setOpenCodeUsageEnabled(true)
|
||||
},
|
||||
|
||||
refreshOpenCodeUsage: async () => {
|
||||
await get().fetchOpenCodeUsage({ forceRefresh: true })
|
||||
}
|
||||
})
|
||||
@@ -84,6 +84,22 @@ const mockApi = {
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
openCodeUsage: {
|
||||
getScanState: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null,
|
||||
hasAnyOpenCodeData: false
|
||||
}),
|
||||
setEnabled: vi.fn().mockResolvedValue({}),
|
||||
refresh: vi.fn().mockResolvedValue({}),
|
||||
getSummary: vi.fn().mockResolvedValue(null),
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +122,7 @@ import { createMemorySlice } from './memory'
|
||||
import { createWorkspaceSpaceSlice } from './workspace-space'
|
||||
import { createClaudeUsageSlice } from './claude-usage'
|
||||
import { createCodexUsageSlice } from './codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './opencode-usage'
|
||||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
@@ -134,6 +151,7 @@ function createTestStore() {
|
||||
...createWorkspaceSpaceSlice(...a),
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createCodexUsageSlice(...a),
|
||||
...createOpenCodeUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createMemorySlice } from './memory'
|
||||
import { createWorkspaceSpaceSlice } from './workspace-space'
|
||||
import { createClaudeUsageSlice } from './claude-usage'
|
||||
import { createCodexUsageSlice } from './codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './opencode-usage'
|
||||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
@@ -60,6 +61,7 @@ export function createTestStore() {
|
||||
...createWorkspaceSpaceSlice(...a),
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createCodexUsageSlice(...a),
|
||||
...createOpenCodeUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a),
|
||||
|
||||
@@ -78,6 +78,22 @@ const mockApi = {
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
openCodeUsage: {
|
||||
getScanState: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null,
|
||||
hasAnyOpenCodeData: false
|
||||
}),
|
||||
setEnabled: vi.fn().mockResolvedValue({}),
|
||||
refresh: vi.fn().mockResolvedValue({}),
|
||||
getSummary: vi.fn().mockResolvedValue(null),
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +116,7 @@ import { createMemorySlice } from './memory'
|
||||
import { createWorkspaceSpaceSlice } from './workspace-space'
|
||||
import { createClaudeUsageSlice } from './claude-usage'
|
||||
import { createCodexUsageSlice } from './codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './opencode-usage'
|
||||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
@@ -130,6 +147,7 @@ function createTestStore() {
|
||||
...createWorkspaceSpaceSlice(...a),
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createCodexUsageSlice(...a),
|
||||
...createOpenCodeUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a),
|
||||
|
||||
@@ -69,6 +69,22 @@ const mockApi = {
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
openCodeUsage: {
|
||||
getScanState: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null,
|
||||
hasAnyOpenCodeData: false
|
||||
}),
|
||||
setEnabled: vi.fn().mockResolvedValue({}),
|
||||
refresh: vi.fn().mockResolvedValue({}),
|
||||
getSummary: vi.fn().mockResolvedValue(null),
|
||||
getDaily: vi.fn().mockResolvedValue([]),
|
||||
getBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getRecentSessions: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MemorySlice } from './slices/memory'
|
||||
import type { WorkspaceSpaceSlice } from './slices/workspace-space'
|
||||
import type { ClaudeUsageSlice } from './slices/claude-usage'
|
||||
import type { CodexUsageSlice } from './slices/codex-usage'
|
||||
import type { OpenCodeUsageSlice } from './slices/opencode-usage'
|
||||
import type { BrowserSlice } from './slices/browser'
|
||||
import type { RateLimitSlice } from './slices/rate-limits'
|
||||
import type { SshSlice } from './slices/ssh'
|
||||
@@ -40,6 +41,7 @@ export type AppState = RepoSlice &
|
||||
WorkspaceSpaceSlice &
|
||||
ClaudeUsageSlice &
|
||||
CodexUsageSlice &
|
||||
OpenCodeUsageSlice &
|
||||
BrowserSlice &
|
||||
RateLimitSlice &
|
||||
SshSlice &
|
||||
|
||||
@@ -16,6 +16,36 @@ export type AutomationRunStatus =
|
||||
export type AutomationRunTrigger = 'scheduled' | 'manual'
|
||||
|
||||
export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly'
|
||||
export type AutomationRunUsageProvider = 'claude' | 'codex'
|
||||
export type AutomationRunUsageStatus = 'known' | 'unavailable'
|
||||
export type AutomationRunUsageAttribution = 'provider_session_time_window'
|
||||
export type AutomationRunUsageUnavailableReason =
|
||||
| 'run_not_finished'
|
||||
| 'provider_unsupported'
|
||||
| 'remote_usage_unavailable'
|
||||
| 'usage_not_enabled'
|
||||
| 'scan_failed'
|
||||
| 'no_matching_session'
|
||||
| 'ambiguous_session'
|
||||
|
||||
export type AutomationRunUsage = {
|
||||
status: AutomationRunUsageStatus
|
||||
provider: AutomationRunUsageProvider | null
|
||||
model: string | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cacheReadTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
reasoningOutputTokens: number | null
|
||||
totalTokens: number | null
|
||||
estimatedCostUsd: number | null
|
||||
estimatedCostSource: 'api_equivalent' | null
|
||||
providerSessionId: string | null
|
||||
attribution: AutomationRunUsageAttribution | null
|
||||
collectedAt: number
|
||||
unavailableReason: AutomationRunUsageUnavailableReason | null
|
||||
unavailableMessage: string | null
|
||||
}
|
||||
|
||||
export type Automation = {
|
||||
id: string
|
||||
@@ -52,6 +82,7 @@ export type AutomationRun = {
|
||||
sessionKind: 'terminal'
|
||||
chatSessionId: string | null
|
||||
terminalSessionId: string | null
|
||||
usage: AutomationRunUsage | null
|
||||
error: string | null
|
||||
startedAt: number | null
|
||||
dispatchedAt: number | null
|
||||
@@ -101,6 +132,7 @@ export type AutomationDispatchResult = {
|
||||
status: AutomationRunStatus
|
||||
workspaceId?: string | null
|
||||
terminalSessionId?: string | null
|
||||
usage?: AutomationRunUsage | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
||||
sshTargets: [],
|
||||
sshRemotePtyLeases: [],
|
||||
migrationUnsupportedPtyEntries: [],
|
||||
legacyPaneKeyAliasEntries: [],
|
||||
automations: [],
|
||||
automationRuns: [],
|
||||
onboarding: getDefaultOnboardingState()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export type OpenCodeUsageScope = 'orca' | 'all'
|
||||
export type OpenCodeUsageRange = '7d' | '30d' | '90d' | 'all'
|
||||
export type OpenCodeUsageBreakdownKind = 'model' | 'project'
|
||||
|
||||
export type OpenCodeUsageScanState = {
|
||||
enabled: boolean
|
||||
isScanning: boolean
|
||||
lastScanStartedAt: number | null
|
||||
lastScanCompletedAt: number | null
|
||||
lastScanError: string | null
|
||||
hasAnyOpenCodeData: boolean
|
||||
}
|
||||
|
||||
export type OpenCodeUsageSummary = {
|
||||
scope: OpenCodeUsageScope
|
||||
range: OpenCodeUsageRange
|
||||
sessions: number
|
||||
events: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
topModel: string | null
|
||||
topProject: string | null
|
||||
hasAnyOpenCodeData: boolean
|
||||
}
|
||||
|
||||
export type OpenCodeUsageDailyPoint = {
|
||||
day: string
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageBreakdownRow = {
|
||||
key: string
|
||||
label: string
|
||||
sessions: number
|
||||
events: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsageSessionRow = {
|
||||
sessionId: string
|
||||
lastActiveAt: string
|
||||
durationMinutes: number
|
||||
projectLabel: string
|
||||
model: string | null
|
||||
events: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isStablePaneId, isTerminalLeafId, makePaneKey, parsePaneKey } from './stable-pane-id'
|
||||
import {
|
||||
isStablePaneId,
|
||||
isTerminalLeafId,
|
||||
makePaneKey,
|
||||
parseLegacyNumericPaneKey,
|
||||
parsePaneKey
|
||||
} from './stable-pane-id'
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -39,4 +45,14 @@ describe('stable pane ids', () => {
|
||||
expect(parsePaneKey(`:${LEAF_ID}`)).toBeNull()
|
||||
expect(parsePaneKey('tab-1:')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses legacy numeric pane keys only for migration aliases', () => {
|
||||
expect(parseLegacyNumericPaneKey(' tab-1:12 ')).toEqual({
|
||||
tabId: 'tab-1',
|
||||
numericPaneId: '12',
|
||||
paneKey: 'tab-1:12'
|
||||
})
|
||||
expect(parseLegacyNumericPaneKey(`tab-1:${LEAF_ID}`)).toBeNull()
|
||||
expect(parseLegacyNumericPaneKey('tab:1:12')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,3 +43,25 @@ export function parsePaneKey(
|
||||
}
|
||||
return { tabId, leafId, stablePaneId: leafId }
|
||||
}
|
||||
|
||||
export function parseLegacyNumericPaneKey(
|
||||
paneKey: unknown
|
||||
): { tabId: string; numericPaneId: string; paneKey: string } | null {
|
||||
if (typeof paneKey !== 'string' || paneKey.length > 256) {
|
||||
return null
|
||||
}
|
||||
const trimmed = paneKey.trim()
|
||||
const delimiter = trimmed.indexOf(':')
|
||||
if (
|
||||
delimiter <= 0 ||
|
||||
delimiter !== trimmed.lastIndexOf(':') ||
|
||||
delimiter === trimmed.length - 1
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const numericPaneId = trimmed.slice(delimiter + 1)
|
||||
if (!/^\d+$/.test(numericPaneId)) {
|
||||
return null
|
||||
}
|
||||
return { tabId: trimmed.slice(0, delimiter), numericPaneId, paneKey: trimmed }
|
||||
}
|
||||
|
||||
@@ -1804,6 +1804,13 @@ export type PersistedTrustedOrcaHookRepo = {
|
||||
|
||||
export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookRepo>
|
||||
|
||||
export type LegacyPaneKeyAliasEntry = {
|
||||
ptyId: string
|
||||
legacyPaneKey: string
|
||||
stablePaneKey: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// ─── Persistence shape ──────────────────────────────────────────────
|
||||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
@@ -1822,6 +1829,7 @@ export type PersistedState = {
|
||||
sshTargets: SshTarget[]
|
||||
sshRemotePtyLeases: SshRemotePtyLease[]
|
||||
migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[]
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
automations: Automation[]
|
||||
automationRuns: AutomationRun[]
|
||||
onboarding: OnboardingState
|
||||
|
||||
@@ -19,17 +19,29 @@ test.describe('usage overview', () => {
|
||||
.poll(async () => getStoreState<string>(orcaPage, 'activeView'), { timeout: 5_000 })
|
||||
.toBe('settings')
|
||||
await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible()
|
||||
const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' })
|
||||
await expect(
|
||||
providerTabs.getByRole('button', { name: 'Overview', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
const providerDropdown = orcaPage.getByTestId('usage-provider-select')
|
||||
await expect(providerDropdown).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Usage analytics provider: Overview'
|
||||
)
|
||||
await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible()
|
||||
await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible()
|
||||
await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Enable OpenCode' })).toBeVisible()
|
||||
|
||||
await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click()
|
||||
await providerDropdown.click()
|
||||
await orcaPage.getByRole('menuitem', { name: 'Codex', exact: true }).click()
|
||||
await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible()
|
||||
await expect(providerDropdown).toHaveAttribute('aria-label', 'Usage analytics provider: Codex')
|
||||
|
||||
await providerDropdown.click()
|
||||
await orcaPage.getByRole('menuitem', { name: 'OpenCode', exact: true }).click()
|
||||
await expect(orcaPage.getByRole('heading', { name: 'OpenCode Usage Tracking' })).toBeVisible()
|
||||
await expect(providerDropdown).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Usage analytics provider: OpenCode'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user