mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-13 10:34:32 +00:00
feat(doctor): probeRoots for 12 more providers (#899 Tier 1)
Each implementation calls the exact resolution helpers its provider's discovery uses (same env fallbacks, same OS branches, same closure overrides), so doctor reports precisely where discovery looks and a stale override or moved data dir stops being a silent $0.00. lingtai-tui deliberately reports its candidates pre-existence-filter: getLingTaiHomes drops non-existent dirs (right for discovery, wrong for doctor, whose job is to show where it looked). tests/provider-probe-roots.test.ts locks the mirroring: where a factory takes an override, the same override must come back through probeRoots.
This commit is contained in:
parent
2c3319b286
commit
800f650dbd
13 changed files with 198 additions and 10 deletions
|
|
@ -8,7 +8,7 @@ import https from 'https'
|
|||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
type AntigravityConversationRoot = {
|
||||
dir: string
|
||||
|
|
@ -1428,6 +1428,13 @@ export function createAntigravityProvider(): Provider {
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [
|
||||
...conversationRoots().map(root => ({ path: root.dir, label: 'conversations' })),
|
||||
{ path: getAntigravityStatusLineEventsPath(), label: 'statusline' },
|
||||
]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverAntigravitySessionSources()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { extractBashCommands } from '../bash-utils.js'
|
|||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import type { ToolCall } from '../types.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
const METADATA_PREFIX_BYTES = 64 * 1024
|
||||
|
||||
|
|
@ -457,6 +457,10 @@ export function createCodeWhaleProvider(overrideDirs?: string | string[]): Provi
|
|||
return mapToolName(rawTool)
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return (configuredDirs ?? defaultSessionDirs()).map(path => ({ path, label: 'sessions' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
const seenSessionIds = new Set<string>()
|
||||
const sources: SessionSource[] = []
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { homedir, platform } from 'os'
|
|||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
/// Crush stores per-project SQLite databases discovered through a JSON registry.
|
||||
/// We only read both. Schema source: charmbracelet/crush
|
||||
|
|
@ -236,6 +236,10 @@ export function createCrushProvider(): Provider {
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: getRegistryPath(), label: 'registry' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const registry = await loadRegistry(getRegistryPath())
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
SessionSource,
|
||||
SessionParser,
|
||||
ParsedProviderCall,
|
||||
ProbeRoot,
|
||||
} from './types.js'
|
||||
|
||||
type ConversationSummary = {
|
||||
|
|
@ -513,6 +514,13 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [
|
||||
{ path: projectsDir, label: 'projects' },
|
||||
{ path: dbPath, label: 'db' },
|
||||
]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!existsSync(projectsDir)) return []
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { readCachedResults, writeCachedResults } from '../cursor-cache.js'
|
|||
import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import type { DateRange } from '../types.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
/** Matches cli-date.ts "all" period cap (6 months). */
|
||||
const CURSOR_MAX_LOOKBACK_MONTHS = 6
|
||||
|
|
@ -1045,6 +1045,10 @@ export function createCursorProvider(dbPathOverride?: string): Provider {
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dbPathOverride ?? getCursorDbPath(), label: 'db' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
SessionSource,
|
||||
SessionParser,
|
||||
ParsedProviderCall,
|
||||
ProbeRoot,
|
||||
} from './types.js'
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
|
|
@ -391,6 +392,10 @@ export function createDroidProvider(factoryDir?: string): Provider {
|
|||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: sessionsDir, label: 'sessions' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInDir(sessionsDir, base)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { calculateCost, getShortModelName } from '../models.js'
|
|||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { ToolCall } from '../types.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
|
|
@ -275,6 +275,10 @@ export function createGooseProvider(): Provider {
|
|||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: getDbPath(), label: 'db' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const dbPath = getDbPath()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ToolCall } from '../types.js'
|
||||
|
||||
type HermesSessionRow = {
|
||||
|
|
@ -462,6 +462,10 @@ export function createHermesProvider(hermesHomeOverride?: string): Provider {
|
|||
return mapToolName(rawTool)
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: hermesHome, label: 'home' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const dbs = await findStateDbs(hermesHome)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { getShortModelName } from '../models.js'
|
||||
import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js'
|
||||
import type { Provider, SessionSource, SessionParser } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
|
||||
|
||||
const PROVIDER_NAME = 'ibm-bob'
|
||||
const DISPLAY_NAME = 'IBM Bob'
|
||||
|
|
@ -45,6 +45,10 @@ export function createIBMBobProvider(overrideDir?: string): Provider {
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return (overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()).map(path => ({ path, label: 'storage' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()
|
||||
return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
|
|
@ -418,6 +418,24 @@ export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptio
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
// Deliberately pre-existence-filter: doctor's job is to show where
|
||||
// discovery looks, so a missing default home reads as "not installed
|
||||
// here" instead of vanishing (getLingTaiHomes drops non-existent
|
||||
// candidates, which is right for discovery and wrong for doctor).
|
||||
const explicit = splitPathList(providerOptions.lingtaiHomeOverride ?? process.env['LINGTAI_HOME'] ?? process.env['LINGTAI_TUI_HOME'])
|
||||
const roots: ProbeRoot[] = explicit.length
|
||||
? explicit.map(path => ({ path, label: 'sessions' }))
|
||||
: [
|
||||
{ path: getDefaultLingTaiHome(providerOptions), label: 'sessions' },
|
||||
{ path: getLingTaiGlobalDir(providerOptions), label: 'registry' },
|
||||
]
|
||||
for (const home of await getLingTaiHomes(providerOptions)) {
|
||||
if (!roots.some(root => root.path === home.path)) roots.push({ path: home.path, label: 'sessions' })
|
||||
}
|
||||
return roots
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverLedgers(await getLingTaiHomes(providerOptions))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { homedir } from 'os'
|
|||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
read_file: 'Read',
|
||||
|
|
@ -162,6 +162,10 @@ export function createQwenProvider(overrideDir?: string): Provider {
|
|||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: projectsDir, label: 'projects' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { extractBashCommands } from '../bash-utils.js'
|
|||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import { safeNumber } from '../parser.js'
|
||||
|
||||
const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp'
|
||||
|
|
@ -479,6 +479,10 @@ export function createWarpProvider(dbPathOverride?: string): Provider {
|
|||
return rawTool === 'run_command' ? 'Bash' : rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return getDbCandidates(dbPathOverride).map(path => ({ path, label: 'db' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
|
||||
|
|
|
|||
118
tests/provider-probe-roots.test.ts
Normal file
118
tests/provider-probe-roots.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { isAbsolute, join } from 'path'
|
||||
|
||||
import { createCodeWhaleProvider } from '../src/providers/codewhale.js'
|
||||
import { createHermesProvider } from '../src/providers/hermes.js'
|
||||
import { createLingTaiTuiProvider } from '../src/providers/lingtai-tui.js'
|
||||
import { createDroidProvider } from '../src/providers/droid.js'
|
||||
import { createCursorProvider } from '../src/providers/cursor.js'
|
||||
import { createCursorAgentProvider } from '../src/providers/cursor-agent.js'
|
||||
import { createGooseProvider } from '../src/providers/goose.js'
|
||||
import { createCrushProvider } from '../src/providers/crush.js'
|
||||
import { createWarpProvider } from '../src/providers/warp.js'
|
||||
import { createAntigravityProvider } from '../src/providers/antigravity.js'
|
||||
import { createQwenProvider } from '../src/providers/qwen.js'
|
||||
import { createIBMBobProvider } from '../src/providers/ibm-bob.js'
|
||||
|
||||
// probeRoots must mirror the exact resolution each provider's discovery uses
|
||||
// (#899 Tier 1). Where a factory takes an override, the assertion is exact:
|
||||
// the same override must come back through probeRoots, proving the two paths
|
||||
// share one resolution. Providers without an override factory get structural
|
||||
// assertions: non-empty, absolute, correctly labeled.
|
||||
|
||||
describe('probeRoots mirrors discovery resolution', () => {
|
||||
it('codewhale reports the configured dirs, or both defaults', async () => {
|
||||
expect(await createCodeWhaleProvider('/tmp/cw-root').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/cw-root', label: 'sessions' },
|
||||
])
|
||||
const defaults = await createCodeWhaleProvider().probeRoots!()
|
||||
expect(defaults).toHaveLength(2)
|
||||
for (const root of defaults) expect(isAbsolute(root.path)).toBe(true)
|
||||
})
|
||||
|
||||
it('hermes reports its resolved home', async () => {
|
||||
expect(await createHermesProvider('/tmp/hermes-home').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/hermes-home', label: 'home' },
|
||||
])
|
||||
})
|
||||
|
||||
it('droid reports the sessions dir under the factory root', async () => {
|
||||
expect(await createDroidProvider('/tmp/factory').probeRoots!()).toEqual([
|
||||
{ path: join('/tmp/factory', 'sessions'), label: 'sessions' },
|
||||
])
|
||||
})
|
||||
|
||||
it('cursor reports the state db path', async () => {
|
||||
expect(await createCursorProvider('/tmp/cursor/state.vscdb').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/cursor/state.vscdb', label: 'db' },
|
||||
])
|
||||
})
|
||||
|
||||
it('cursor-agent reports the projects dir and the attribution db', async () => {
|
||||
expect(await createCursorAgentProvider('/tmp/ca').probeRoots!()).toEqual([
|
||||
{ path: join('/tmp/ca', 'projects'), label: 'projects' },
|
||||
{ path: join('/tmp/ca', 'ai-tracking', 'ai-code-tracking.db'), label: 'db' },
|
||||
])
|
||||
})
|
||||
|
||||
it('warp reports the override db, or both bundle candidates', async () => {
|
||||
expect(await createWarpProvider('/tmp/warp.db').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/warp.db', label: 'db' },
|
||||
])
|
||||
const defaults = await createWarpProvider().probeRoots!()
|
||||
expect(defaults).toHaveLength(2)
|
||||
for (const root of defaults) {
|
||||
expect(isAbsolute(root.path)).toBe(true)
|
||||
expect(root.label).toBe('db')
|
||||
}
|
||||
})
|
||||
|
||||
it('qwen reports the projects dir', async () => {
|
||||
expect(await createQwenProvider('/tmp/qwen-projects').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/qwen-projects', label: 'projects' },
|
||||
])
|
||||
})
|
||||
|
||||
it('ibm-bob reports the storage dirs', async () => {
|
||||
expect(await createIBMBobProvider('/tmp/bob').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/bob', label: 'storage' },
|
||||
])
|
||||
const defaults = await createIBMBobProvider().probeRoots!()
|
||||
expect(defaults.length).toBeGreaterThan(0)
|
||||
for (const root of defaults) expect(root.label).toBe('storage')
|
||||
})
|
||||
|
||||
it('lingtai-tui reports its candidates even when none exist yet', async () => {
|
||||
// getLingTaiHomes drops non-existent candidates (right for discovery);
|
||||
// probeRoots must keep them visible so doctor can show where it looked.
|
||||
const roots = await createLingTaiTuiProvider().probeRoots!()
|
||||
expect(roots.length).toBeGreaterThanOrEqual(2)
|
||||
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
|
||||
const labels = new Set(roots.map(r => r.label))
|
||||
expect(labels.has('sessions')).toBe(true)
|
||||
expect(labels.has('registry')).toBe(true)
|
||||
})
|
||||
|
||||
it('goose reports its sessions db', async () => {
|
||||
const roots = await createGooseProvider().probeRoots!()
|
||||
expect(roots).toHaveLength(1)
|
||||
expect(isAbsolute(roots[0]!.path)).toBe(true)
|
||||
expect(roots[0]!.label).toBe('db')
|
||||
})
|
||||
|
||||
it('crush reports its registry file', async () => {
|
||||
const roots = await createCrushProvider().probeRoots!()
|
||||
expect(roots).toHaveLength(1)
|
||||
expect(isAbsolute(roots[0]!.path)).toBe(true)
|
||||
expect(roots[0]!.label).toBe('registry')
|
||||
})
|
||||
|
||||
it('antigravity reports its conversation roots and the statusline file', async () => {
|
||||
const roots = await createAntigravityProvider().probeRoots!()
|
||||
expect(roots.length).toBeGreaterThanOrEqual(2)
|
||||
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
|
||||
const labels = new Set(roots.map(r => r.label))
|
||||
expect(labels.has('conversations')).toBe(true)
|
||||
expect(labels.has('statusline')).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue