Files
hapi/cli/src/commands/usageScan.ts
T
wushenghua 2d57763177 fix(hub,cli): run OpenCode usage scans off the process event loop
The 5-minute reconciliation read the multi-GB OpenCode store on the hub's
event loop with bun:sqlite; a cold scan froze every request for ~88s and
stalled each machine's runner the same way.

- scan in a spawned `hapi usage-scan` child process (stdin/stdout JSON)
- refresh on demand from the usage page (debounced) + a daily fallback
- a runner whose hub is localhost skips the store the hub already owns
- machine scans run every 30min and read the message table sequentially
  in one pass instead of per-session indexed lookups
2026-10-16 19:38:07 +08:00

63 lines
2.6 KiB
TypeScript

import { readFileSync } from 'node:fs'
import type { CommandDefinition } from './types'
/**
* Internal command: aggregate OpenCode token usage.
*
* The hub and the runner spawn `hapi usage-scan` as a child process so the
* synchronous bun:sqlite reads over the multi-GB OpenCode store never block
* their event loops (a cold scan used to freeze the hub for a minute or
* more). The request arrives as JSON on stdin, the result leaves as JSON on
* stdout: `{ dbPath, sessionIds }` for known sessions, or
* `{ dbPath, listAll: true }` to scan every session found in the store.
*/
export type UsageScanRequest = {
dbPath: string
sessionIds?: string[]
listAll?: boolean
}
function readStdin(): string {
// fd 0 works for both pipe and file-backed stdin; Bun's async iteration
// over a file-backed stdin yields nothing.
return readFileSync(0, 'utf8')
}
export function parseUsageScanRequest(raw: string): UsageScanRequest {
const parsed = JSON.parse(raw) as { dbPath?: unknown; sessionIds?: unknown; listAll?: unknown }
if (typeof parsed.dbPath !== 'string') {
throw new Error('usage-scan expects { dbPath: string, sessionIds?: string[], listAll?: boolean } on stdin')
}
if (parsed.listAll === true) {
return { dbPath: parsed.dbPath, listAll: true }
}
if (!Array.isArray(parsed.sessionIds)) {
throw new Error('usage-scan expects sessionIds or listAll: true on stdin')
}
const sessionIds = parsed.sessionIds.filter((id): id is string => typeof id === 'string' && id.length > 0)
return { dbPath: parsed.dbPath, sessionIds }
}
/** Payload shape the hub/runner parse (see hub/src/sync/usageReconciliation.ts). */
export async function runUsageScan(request: UsageScanRequest): Promise<{ results: Record<string, { rows: unknown[]; messages: number }> }> {
const { readOpencodeUsageForAllSessions, readOpencodeUsageForSessions } = await import('@hapi/protocol/opencodeUsage')
const usage = request.listAll === true
? await readOpencodeUsageForAllSessions(request.dbPath)
: await readOpencodeUsageForSessions(request.dbPath, request.sessionIds ?? [])
const results: Record<string, { rows: unknown[]; messages: number }> = {}
for (const [sessionId, entry] of usage) {
results[sessionId] = { rows: entry.rows, messages: entry.messages }
}
return { results }
}
export const usageScanCommand: CommandDefinition = {
name: 'usage-scan',
requiresRuntimeAssets: false,
run: async () => {
const request = parseUsageScanRequest(readStdin())
process.stdout.write(JSON.stringify(await runUsageScan(request)))
}
}