From bb2afe1792e6e122015cc75f474ef5d3addf8463 Mon Sep 17 00:00:00 2001 From: latte Date: Sat, 19 Sep 2026 17:32:12 +0700 Subject: [PATCH] fix(ai-vault): discover and parse Devin sessions on Windows (#21337) * fix(ai-vault): discover and parse Devin sessions on Windows, restore workspace mapping Devin sessions never appeared in the AI Vault on Windows, and parsed nearly empty elsewhere: - The transcripts root hardcoded the XDG layout (~/.local/share/devin/cli/transcripts), but Devin CLI writes under %APPDATA%/devin/cli/transcripts on Windows. The root is now platform-aware (APPDATA on win32, XDG_DATA_HOME elsewhere) for both local scans and win32 remote hosts, and APPDATA joins the scanner child's env allowlist so relocated AppData resolves. - The parser read metadata.is_user_input / created_at / metrics, which real ATIF-v1.7 transcripts don't carry. It now also accepts the ATIF step shape (source, timestamp, step-level metrics/model_name, plain-string message) while keeping the legacy shape. - ATIF transcripts carry no working_directory, so sessions couldn't group under a workspace. The sibling sessions.db index is now merged through the existing sidecar seam: it fills cwd/title/model/ timestamps, honors the db's hidden flag, and re-merges on db-only changes without re-reading transcripts. * fix(ai-vault): inline Devin transcripts root, harden parser/db edge cases - Resolve the platform-aware Devin cli dir in agent-sources instead of importing the shared devin-cli-data-dir module, which is not part of this change (broke typecheck). - Exclude source:'system' steps unconditionally, even when legacy metadata fields would classify them as user/assistant messages. - Guard unix-seconds conversion against out-of-range values so a single bad sessions.db row cannot mark the whole index unreadable. * fix(ai-vault): watch sessions.db-wal so live Devin metadata cannot go stale In WAL mode, committed rows sit in sessions.db-wal while sessions.db keeps its stat until checkpoint, so keying the dependency on the db alone could serve a stale index. The dependency now observes the wal when one exists; the reader still opens sessions.db itself. * fix(ai-vault): probe sessions.db-wal through the WSL-gated stat existsSync bypasses wslGatedStat and can hang a scan on a stalled 9P mount; the fs-import guard forbids it in session-scanner modules. The dependency path resolution is now async and probes through the gate. * fix(ai-vault): honor zero metrics and array messages in Devin steps - firstDevinMetricValue skipped explicit numeric zeros, letting a lower-priority positive metric win and overstating token totals. - ATIF allows step.message as an array of content parts; route it through extractContentText so those steps still feed title/preview. * test(ai-vault): cover array-valued ATIF message extraction The extractDevinStepText fallback that routes an array-valued step.message through extractContentText shipped without a fixture that produces that shape, so a future refactor could silently drop the branch. Pin that an array of text parts feeds the step's title and preview. * fix(ai-vault): invalidate old Devin caches and bound database retries * Discover current Devin ATIF exports alongside legacy transcripts * Recognize drawn geometry in the browser markup contract test * Deduplicate Devin exports across transcript roots * Account for the workspace sleep-state reader in scan budget * Align OMP integration tests with recorded-path resume * fix: update scan benchmarks and await relay environment test --------- Co-authored-by: Neil --- config/oxlint-anti-slop.json | 5 +- .../session-dedup-batches-benchmark.mjs | 18 +- .../scripts/session-scan-cutoff-benchmark.mjs | 10 +- .../ai-vault/codex-session-collection.test.ts | 10 +- .../ai-vault/codex-session-root-dedup.test.ts | 22 +- src/main/ai-vault/codex-session-root-dedup.ts | 124 +---- .../remote-session-large-transcripts.test.ts | 7 +- .../remote-session-scanner-devin-source.ts | 38 ++ .../remote-session-scanner-sources.test.ts | 36 ++ .../remote-session-scanner-sources.ts | 12 +- src/main/ai-vault/remote-session-scanner.ts | 14 +- .../session-parse-cache-devin-upgrade.test.ts | 77 +++ .../session-parse-cache-persistence.ts | 2 +- src/main/ai-vault/session-root-dedup.ts | 146 ++++++ src/main/ai-vault/session-scan-cutoff.test.ts | 6 +- src/main/ai-vault/session-scan-cutoff.ts | 4 +- ...ssion-scanner-agent-root-overrides.test.ts | 69 ++- .../ai-vault/session-scanner-agent-sources.ts | 34 +- .../session-scanner-devin-contention.test.ts | 55 ++ .../ai-vault/session-scanner-devin-db.test.ts | 468 ++++++++++++++++++ src/main/ai-vault/session-scanner-devin-db.ts | 205 ++++++++ .../session-scanner-devin-dedup.test.ts | 108 ++++ .../session-scanner-devin-parser.test.ts | 118 +++++ .../ai-vault/session-scanner-devin-parser.ts | 62 ++- .../ai-vault/session-scanner-parse-cache.ts | 21 +- .../session-scanner-service-env.test.ts | 6 + .../ai-vault/session-scanner-service-env.ts | 3 + .../session-scanner-sidecar-enrichment.ts | 61 ++- .../session-scanner-unlimited-dedup.test.ts | 26 +- src/main/ai-vault/session-scanner.ts | 149 +++--- src/main/pi/omp-transcript-metadata.test.ts | 4 +- .../omp-fresh-launch-environment.test.ts | 2 +- ...e-title-update-global-scan-budget.test.tsx | 10 +- .../omp-child-session-recovery.unit.test.ts | 7 +- 34 files changed, 1626 insertions(+), 313 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-devin-source.ts create mode 100644 src/main/ai-vault/remote-session-scanner-sources.test.ts create mode 100644 src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts create mode 100644 src/main/ai-vault/session-root-dedup.ts create mode 100644 src/main/ai-vault/session-scanner-devin-contention.test.ts create mode 100644 src/main/ai-vault/session-scanner-devin-db.test.ts create mode 100644 src/main/ai-vault/session-scanner-devin-db.ts create mode 100644 src/main/ai-vault/session-scanner-devin-dedup.test.ts diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 53d735c894e..4728c38a7e9 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -82,7 +82,10 @@ } }, { - "files": ["**/browser-pane/annotate/**"], + "files": [ + "**/browser-pane/annotate/**", + "**/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx" + ], "rules": { "anti-slop/no-shape-in-symbol-names": "off" } diff --git a/config/scripts/session-dedup-batches-benchmark.mjs b/config/scripts/session-dedup-batches-benchmark.mjs index a8c1855f21f..619cc8373a7 100644 --- a/config/scripts/session-dedup-batches-benchmark.mjs +++ b/config/scripts/session-dedup-batches-benchmark.mjs @@ -5,7 +5,7 @@ import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedu const bundled = await build({ stdin: { - contents: "export * from './src/main/ai-vault/codex-session-root-dedup.ts'", + contents: "export * from './src/main/ai-vault/session-root-dedup.ts'", resolveDir: process.cwd(), loader: 'ts' }, @@ -22,14 +22,14 @@ function baseline(input) { const sessions = [] for (let offset = 0; offset < input.length; offset += 8) { sessions.push(...input.slice(offset, offset + 8)) - const unique = production.dedupeCodexSessionsBySessionId(sessions) + const unique = production.dedupeScannedSessions(sessions) sessions.splice(0, sessions.length, ...unique) } return sessions } function incremental(input) { - const sessions = new production.CodexSessionCollection() + const sessions = new production.ScannedSessionCollection() for (let offset = 0; offset < input.length; offset += 8) { for (const session of input.slice(offset, offset + 8)) { sessions.add(session) @@ -63,11 +63,11 @@ function random(max) { return Math.floor((seed / 0x100000000) * max) } -if (production.CodexSessionCollection) { +if (production.ScannedSessionCollection) { let batches = 0 for (let trial = 0; trial < 2000; trial++) { const input = [] - const current = new production.CodexSessionCollection() + const current = new production.ScannedSessionCollection() let expected = [] for (let batch = 0; batch < 20; batch++) { const added = Array.from({ length: 1 + random(8) }, () => { @@ -96,7 +96,7 @@ if (production.CodexSessionCollection) { }) }) input.push(...added) - expected = production.dedupeCodexSessionsBySessionId([...expected, ...added]) + expected = production.dedupeScannedSessions([...expected, ...added]) added.forEach((session) => current.add(session)) checkIdentities([...current.values()], expected) assert.equal(current.size, expected.length) @@ -141,7 +141,7 @@ console.log( ) for (const [name, input] of workloads) { const expected = baseline(input) - const arms = { baseline, ...(production.CodexSessionCollection ? { incremental } : {}) } + const arms = { baseline, ...(production.ScannedSessionCollection ? { incremental } : {}) } const repeats = Math.max(1, Math.floor(5000 / input.length)) const samples = { baseline: [], incremental: [] } for (const run of Object.values(arms)) { @@ -173,12 +173,12 @@ for (const [name, input] of workloads) { ) } -if (global.gc && production.CodexSessionCollection) { +if (global.gc && production.ScannedSessionCollection) { for (const count of [1000, 10000]) { const input = Array.from({ length: count }, (_, index) => makeSession(index)) global.gc() const before = process.memoryUsage().heapUsed - const collection = new production.CodexSessionCollection() + const collection = new production.ScannedSessionCollection() input.forEach((session) => collection.add(session)) global.gc() const retainedBytes = process.memoryUsage().heapUsed - before diff --git a/config/scripts/session-scan-cutoff-benchmark.mjs b/config/scripts/session-scan-cutoff-benchmark.mjs index 4353754199e..08c03e69b42 100644 --- a/config/scripts/session-scan-cutoff-benchmark.mjs +++ b/config/scripts/session-scan-cutoff-benchmark.mjs @@ -35,11 +35,11 @@ const [baselineModule, currentModule] = await Promise.all([ load(`import { sessionSortTime } from './session-scanner-accumulator'; export ${baselineFunction.getText(baselineSource)}`), load(`export { canStopParsingSessions } from './session-scan-cutoff'; -export { CodexSessionCollection } from './codex-session-root-dedup';`) +export { ScannedSessionCollection } from './session-root-dedup';`) ]) const baseline = baselineModule.canStopParsingSessions const current = currentModule.canStopParsingSessions -const { CodexSessionCollection } = currentModule +const { ScannedSessionCollection } = currentModule let randomState = 91114 function random(bound) { @@ -59,7 +59,7 @@ function session(index, overrides = {}) { }) } function collection(rows) { - const result = new CodexSessionCollection() + const result = new ScannedSessionCollection() for (const row of rows) { result.add(row) } @@ -85,7 +85,7 @@ const limits = [0, -1, -3, 0.5, 1.5, Number.NaN, Infinity, -Infinity] const nextTimes = [undefined, Number.NaN, Infinity, -Infinity, 0, 1, 2, 2000] let comparisons = 0 for (let trial = 0; trial < 4_000; trial += 1) { - const sessions = new CodexSessionCollection() + const sessions = new ScannedSessionCollection() const admitted = [] for (let batch = 0; batch < 10; batch += 1) { const count = random(8) @@ -180,7 +180,7 @@ results.push( measure( '2000-candidate scan cutoff + admission / limit1000', (cutoff) => { - const sessions = new CodexSessionCollection() + const sessions = new ScannedSessionCollection() let index = 0 while (index < rows.length && !cutoff(sessions, 1_000, 10_000)) { const end = Math.min(rows.length, index + Math.min(8, Math.max(1, 1_000 - sessions.size))) diff --git a/src/main/ai-vault/codex-session-collection.test.ts b/src/main/ai-vault/codex-session-collection.test.ts index 0e815f1b279..a20020f05ca 100644 --- a/src/main/ai-vault/codex-session-collection.test.ts +++ b/src/main/ai-vault/codex-session-collection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' import { createAccumulator, finalizeSession } from './session-scanner-accumulator' function session(overrides: Partial = {}): AiVaultSession { @@ -23,10 +23,10 @@ function session(overrides: Partial = {}): AiVaultSession { } function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] { - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() let expected: AiVaultSession[] = [] for (const batch of batches) { - expected = dedupeCodexSessionsBySessionId([...expected, ...batch]) + expected = dedupeScannedSessions([...expected, ...batch]) for (const value of batch) { collection.add(value) } @@ -38,7 +38,7 @@ function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] { return [...collection.values()] } -describe('CodexSessionCollection', () => { +describe('ScannedSessionCollection', () => { it('keeps winner occurrences in input order across replacements and batches', () => { const other = session({ agent: 'claude' }) const real = session() @@ -141,7 +141,7 @@ describe('CodexSessionCollection', () => { it('does not rescan retained rows on admission', () => { let pathReads = 0 - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() for (let index = 0; index < 1000; index++) { const value = session({ sessionId: `session-${index}` }) collection.add({ diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index 519245c7618..cf61e3808c3 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../shared/ai-vault-types' import { dedupeCodexRolloutCopyAliases, - dedupeCodexRolloutFileAliases, - dedupeCodexSessionsBySessionId + dedupeCodexRolloutFileAliases } from './codex-session-root-dedup' +import { dedupeScannedSessions } from './session-root-dedup' const REAL_HOME_ROLLOUT = '/Users/ada/.codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' @@ -305,7 +305,7 @@ describe('dedupeCodexRolloutCopyAliases', () => { }) }) -describe('dedupeCodexSessionsBySessionId', () => { +describe('dedupeScannedSessions', () => { it('collapses a both-roots session to the real-home row', () => { const managed = codexSession({ filePath: MANAGED_HOME_ROLLOUT, @@ -317,8 +317,8 @@ describe('dedupeCodexSessionsBySessionId', () => { codexHome: null, id: `local:codex:session-1:${REAL_HOME_ROLLOUT}` }) - expect(dedupeCodexSessionsBySessionId([managed, real])).toEqual([real]) - expect(dedupeCodexSessionsBySessionId([real, managed])).toEqual([real]) + expect(dedupeScannedSessions([managed, real])).toEqual([real]) + expect(dedupeScannedSessions([real, managed])).toEqual([real]) }) it('keeps managed-only and real-only sessions unchanged', () => { @@ -332,7 +332,7 @@ describe('dedupeCodexSessionsBySessionId', () => { filePath: REAL_HOME_ROLLOUT, codexHome: null }) - expect(dedupeCodexSessionsBySessionId([managedOnly, realOnly])).toEqual([managedOnly, realOnly]) + expect(dedupeScannedSessions([managedOnly, realOnly])).toEqual([managedOnly, realOnly]) }) it('never collapses across execution hosts or agents', () => { @@ -352,7 +352,7 @@ describe('dedupeCodexSessionsBySessionId', () => { agent: 'claude', filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl' }) - expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude]) + expect(dedupeScannedSessions([local, remote, claude])).toEqual([local, remote, claude]) }) it('preserves same-host session-id collisions when rollout file names differ', () => { @@ -370,7 +370,7 @@ describe('dedupeCodexSessionsBySessionId', () => { updatedAt: '2026-07-02T10:00:00.000Z', modifiedAt: '2026-07-02T10:00:00.000Z' }) - expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([older, newer]) + expect(dedupeScannedSessions([older, newer])).toEqual([older, newer]) }) it('resolves same-rollout aliases with a stable path tie-break', () => { @@ -384,7 +384,7 @@ describe('dedupeCodexSessionsBySessionId', () => { filePath: '/Users/ada/b/.codex/sessions/2026/07/01/rollout-tie.jsonl', codexHome: null }) - expect(dedupeCodexSessionsBySessionId([tieB, tieA])).toEqual([tieA]) + expect(dedupeScannedSessions([tieB, tieA])).toEqual([tieA]) }) it('prefers the managed runtime home over a WSL real home when no host real-home row exists', () => { @@ -399,7 +399,7 @@ describe('dedupeCodexSessionsBySessionId', () => { filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-a.jsonl', codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' }) - expect(dedupeCodexSessionsBySessionId([wslReal, wslManaged])).toEqual([wslManaged]) + expect(dedupeScannedSessions([wslReal, wslManaged])).toEqual([wslManaged]) }) it('never collapses matching host and WSL session identities', () => { @@ -416,6 +416,6 @@ describe('dedupeCodexSessionsBySessionId', () => { '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home' }) - expect(dedupeCodexSessionsBySessionId([host, wsl])).toEqual([host, wsl]) + expect(dedupeScannedSessions([host, wsl])).toEqual([host, wsl]) }) }) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index 19512079bb0..061a05aaf8e 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -223,127 +223,7 @@ export async function dedupeCodexRolloutCopyAliases( return candidates.filter((candidate) => !aliasesToDrop.has(candidate)) } -/** - * Collapses parsed Codex sessions that share a rollout name and session id on - * one execution host, keeping the canonical root's row. Requiring both the - * parsed id and rollout name preserves id collisions and same-name files whose - * parsed ids differ. - */ -export function dedupeCodexSessionsBySessionId( - sessions: readonly AiVaultSession[] -): AiVaultSession[] { - const bestByKey = new Map() - for (const session of sessions) { - const key = codexSessionAliasKey(session) - if (!key) { - continue - } - const best = bestByKey.get(key) - if (!best || codexSessionAliasBeats(session, best)) { - bestByKey.set(key, session) - } - } - return sessions.filter((session) => { - const key = codexSessionAliasKey(session) - if (!key) { - return true - } - return bestByKey.get(key) === session - }) -} - -type CodexSessionWinner = { session: AiVaultSession; indices: number | number[] } - -/** Scan-local accumulation; parsed rows must not be mutated after admission. */ -export class CodexSessionCollection { - private readonly sessions = new Map() - // Keyed by the row's own sessionId string, so an unlimited scan retains no - // alias key per live row; a per-alias-key map appears only for the rare id - // that spans several hosts, namespaces, or rollout names. - private readonly winnersBySessionId = new Map< - string, - CodexSessionWinner | Map - >() - private nextIndex = 0 - - get size(): number { - return this.sessions.size - } - - values(): IterableIterator { - return this.sessions.values() - } - - add(session: AiVaultSession): void { - const key = codexSessionAliasKey(session) - const index = this.nextIndex++ - if (key && !this.admit(session, key, index)) { - return - } - this.sessions.set(index, session) - } - - /** Whether the row is retained; a losing alias is dropped. */ - private admit(session: AiVaultSession, key: string, index: number): boolean { - const bucket = this.winnersBySessionId.get(session.sessionId) - if (bucket instanceof Map) { - const winner = this.contest(bucket.get(key), session, index) - if (winner) { - bucket.set(key, winner) - } - return winner !== null - } - const bucketKey = bucket && codexSessionAliasKey(bucket.session) - if (bucket && bucketKey && bucketKey !== key) { - this.winnersBySessionId.set( - session.sessionId, - new Map([ - [bucketKey, bucket], - [key, { session, indices: index }] - ]) - ) - return true - } - const winner = this.contest(bucket, session, index) - if (winner) { - this.winnersBySessionId.set(session.sessionId, winner) - } - return winner !== null - } - - /** The alias key's winner after this row, or null when the row loses. */ - private contest( - best: CodexSessionWinner | undefined, - session: AiVaultSession, - index: number - ): CodexSessionWinner | null { - if (!best) { - return { session, indices: index } - } - if (best.session === session) { - // The batch filter retains every occurrence of the winning object. - if (typeof best.indices === 'number') { - best.indices = [best.indices, index] - } else { - best.indices.push(index) - } - return best - } - if (!codexSessionAliasBeats(session, best.session)) { - return null - } - if (typeof best.indices === 'number') { - this.sessions.delete(best.indices) - } else { - for (const previousIndex of best.indices) { - this.sessions.delete(previousIndex) - } - } - return { session, indices: index } - } -} - -function codexSessionAliasKey(session: AiVaultSession): string | null { +export function codexSessionAliasKey(session: AiVaultSession): string | null { if (session.agent !== 'codex') { return null } @@ -354,7 +234,7 @@ function codexSessionAliasKey(session: AiVaultSession): string | null { return `${session.executionHostId}\0${codexPathExecutionNamespace(session.filePath)}\0${session.sessionId}\0${fileName}` } -function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { +export function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { const candidateRank = codexSessionRootRank(candidate.codexHome) const bestRank = codexSessionRootRank(best.codexHome) if (candidateRank !== bestRank) { diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts index df05700c2cf..73c33aec27f 100644 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -37,7 +37,7 @@ describe('large remote history through real relay filesystem', () => { expect(result.sessions.map((session) => session.sessionId)).toEqual(['good']) expect(result.issues).toEqual([ expect.objectContaining({ - path: badPath, + path: badPath.replace(/\\/g, '/'), message: 'Session transcript record exceeds 10485760 byte limit' }) ]) @@ -128,7 +128,10 @@ describe('large remote history through real relay filesystem', () => { path = join(home, '.hermes', 'sessions', 'large.json') record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages } } else if (agent === 'devin') { - path = join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') + path = + platform.os === 'win32' + ? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json') + : join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') record = { session_id: 'large', working_directory: '/repo', diff --git a/src/main/ai-vault/remote-session-scanner-devin-source.ts b/src/main/ai-vault/remote-session-scanner-devin-source.ts new file mode 100644 index 00000000000..8244dd6e625 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-devin-source.ts @@ -0,0 +1,38 @@ +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { remoteSessionDocumentParsers } from './remote-session-document-parsers' +import type { RemoteSessionSource } from './remote-session-scanner-types' +import { parseDevinSessionContent } from './session-scanner-devin-parser' + +// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and +// ~/.local/share on posix ones. +function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] { + return hostPlatform.os === 'win32' + ? ['AppData', 'Roaming', 'devin', 'cli'] + : ['.local', 'share', 'devin', 'cli'] +} + +export function remoteDevinSource( + remoteHome: string, + hostPlatform: RemoteHostPlatform, + directory: 'transcripts' | 'agent_logs' = 'transcripts' +): RemoteSessionSource { + return { + agent: 'devin', + rootDir: joinRemotePath( + hostPlatform, + remoteHome, + ...remoteDevinDataSegments(hostPlatform), + directory + ), + extensions: ['.json'], + ...remoteSessionDocumentParsers('devin'), + parse: (file, content, context) => + Promise.resolve( + parseDevinSessionContent(file, content, context.hostPlatform.os, { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }) + ) + } +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.test.ts b/src/main/ai-vault/remote-session-scanner-sources.test.ts new file mode 100644 index 00000000000..aa492565ee4 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-sources.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { RelayPlatform } from '../ssh/relay-protocol' +import { remoteSessionSources } from './remote-session-scanner-sources' + +function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] { + const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform) + return remoteSessionSources(remoteHome, hostPlatform) + .filter((source) => source.agent === 'devin') + .map((source) => source.rootDir) +} + +describe('remoteSessionSources devin transcripts root', () => { + it.each([ + { + relayPlatform: 'win32-x64' as const, + remoteHome: 'C:/Users/dev', + expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts' + }, + { + relayPlatform: 'linux-x64' as const, + remoteHome: '/home/dev', + expected: '/home/dev/.local/share/devin/cli/transcripts' + }, + { + relayPlatform: 'darwin-arm64' as const, + remoteHome: '/Users/dev', + expected: '/Users/dev/.local/share/devin/cli/transcripts' + } + ])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => { + expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([ + expected, + expected.replace(/transcripts$/, 'agent_logs') + ]) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 92b9e9b4dc5..8a44061e463 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -6,7 +6,6 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform' import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' -import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' @@ -20,6 +19,7 @@ import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' import { remoteClineSource } from './remote-session-scanner-cline-source' +import { remoteDevinSource } from './remote-session-scanner-devin-source' import type { RemoteParserOptions, RemoteScannerContext, @@ -89,14 +89,8 @@ export function remoteSessionSources( ['.json'], parseHermesSessionContent ), - source( - 'devin', - remoteHome, - hostPlatform, - ['.local', 'share', 'devin', 'cli', 'transcripts'], - ['.json'], - parseDevinSessionContent - ), + remoteDevinSource(remoteHome, hostPlatform), + remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), { ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 971bc20f70e..2ebaba9103a 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -10,11 +10,10 @@ import type { ExecutionHostId } from '../../shared/execution-host' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { - CodexSessionCollection, codexRolloutHardlinkIdentity, - dedupeCodexRolloutFileAliases, - dedupeCodexSessionsBySessionId + dedupeCodexRolloutFileAliases } from './codex-session-root-dedup' +import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' import { parseRemoteSessionFileCached, remoteSessionParseHostKey @@ -107,7 +106,7 @@ export async function scanRemoteAiVaultSessions(args: { issues, limit }) - const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) + const parsedSessions = dedupeScannedSessions(parsed.sessions) const cappedSessions = parsedSessions .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) @@ -123,10 +122,7 @@ export async function scanRemoteAiVaultSessions(args: { limit, alreadyParsedFilePaths: parsed.parsedFilePaths }) - const scopeSessions = dedupeCodexSessionsBySessionId([ - ...parsedScopeSessions, - ...extraScopeSessions - ]) + const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions]) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) @@ -143,7 +139,7 @@ async function parseRemoteSessionCandidates(args: { issues: AiVaultScanIssue[] limit: number }): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> { - const sessions = new CodexSessionCollection() + const sessions = new ScannedSessionCollection() const parsedFilePaths = new Set() let index = 0 diff --git a/src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts b/src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts new file mode 100644 index 00000000000..f80a91052da --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { + ensureSessionParseCacheLoaded, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + snapshotSessionParseCacheForPersistence +} from './session-scanner-parse-cache' +import type { SessionFileCandidate } from './session-scanner-types' + +let root: string | undefined + +afterEach(async () => { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('reparses unchanged ATIF transcripts cached before Devin source fields were supported', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-devin-upgrade-')) + const path = join(root, 'devin-session.json') + await writeFile( + path, + JSON.stringify({ + session_id: 'devin-session', + steps: [{ source: 'user', message: 'Find the missing sessions' }] + }) + ) + const fileStat = await stat(path) + const candidate: SessionFileCandidate = { + agent: 'devin', + codexHome: null, + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + sidecar: 'none' + } + } + await parseAgentSessionFileCached(candidate, process.platform) + const entries = snapshotSessionParseCacheForPersistence().map(([filePath, entry]) => [ + filePath, + { + ...entry, + session: entry.session && { + ...entry.session, + title: 'Devin session devin-session', + messageCount: 0, + previewMessages: [], + firstUserPrompt: null, + lastUserPrompt: null + } + } + ]) + const cacheFile = join(root, 'cache.json') + await writeFile(cacheFile, JSON.stringify({ schemaVersion: 2, appVersion: 'old', entries })) + resetSessionParseCacheForTests() + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'new' }) + await ensureSessionParseCacheLoaded() + + const stats = createSessionParseStats() + const session = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + expect(session?.messageCount).toBe(1) + expect(session?.title).toBe('Find the missing sessions') +}) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index a394ea2ad91..7e7475e6a5b 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -15,7 +15,7 @@ import type { SessionSidecarObservation } from './session-sidecar-stat' // Bump when the persisted entry layout or cached session semantics change; a // mismatched file is discarded whole. -const SCHEMA_VERSION = 2 +const SCHEMA_VERSION = 3 // Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write. const SAVE_DEBOUNCE_MS = 1_500 // The payload contains transcript-derived preview text; keep it user-only diff --git a/src/main/ai-vault/session-root-dedup.ts b/src/main/ai-vault/session-root-dedup.ts new file mode 100644 index 00000000000..da1c239c76c --- /dev/null +++ b/src/main/ai-vault/session-root-dedup.ts @@ -0,0 +1,146 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { codexSessionAliasKey, codexSessionAliasBeats } from './codex-session-root-dedup' +import { sessionSortTime } from './session-scanner-accumulator' + +function sessionAliasKey(session: AiVaultSession): string | null { + if (session.agent !== 'devin') { + const codexKey = codexSessionAliasKey(session) + return codexKey ? `codex\0${codexKey}` : null + } + // Sibling exports share an index; different installs and WSL distros do not. + const cliDir = session.filePath.split(/[\\/]/).slice(0, -2).join('/') + return `devin\0${session.executionHostId}\0${cliDir}\0${session.sessionId}` +} + +function sessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { + if (candidate.agent !== 'devin') { + return codexSessionAliasBeats(candidate, best) + } + const candidateTime = sessionSortTime(candidate) + const bestTime = sessionSortTime(best) + if (candidateTime !== bestTime) { + return candidateTime > bestTime + } + // The database can give both exports the same activity time. + if (candidate.modifiedAt !== best.modifiedAt) { + return Date.parse(candidate.modifiedAt) > Date.parse(best.modifiedAt) + } + const candidateCurrent = candidate.filePath.split(/[\\/]/).at(-2) === 'agent_logs' + const bestCurrent = best.filePath.split(/[\\/]/).at(-2) === 'agent_logs' + if (candidateCurrent !== bestCurrent) { + return candidateCurrent + } + return candidate.filePath < best.filePath +} + +export function dedupeScannedSessions(sessions: readonly AiVaultSession[]): AiVaultSession[] { + const bestByKey = new Map() + for (const session of sessions) { + const key = sessionAliasKey(session) + if (!key) { + continue + } + const best = bestByKey.get(key) + if (!best || sessionAliasBeats(session, best)) { + bestByKey.set(key, session) + } + } + return sessions.filter((session) => { + const key = sessionAliasKey(session) + if (!key) { + return true + } + return bestByKey.get(key) === session + }) +} + +type SessionWinner = { session: AiVaultSession; indices: number | number[] } + +/** Scan-local accumulation; parsed rows must not be mutated after admission. */ +export class ScannedSessionCollection { + private readonly sessions = new Map() + // Keyed by the row's own sessionId string, so an unlimited scan retains no + // alias key per live row; a per-alias-key map appears only for the rare id + // that spans several hosts, namespaces, or rollout names. + private readonly winnersBySessionId = new Map< + string, + SessionWinner | Map + >() + private nextIndex = 0 + + get size(): number { + return this.sessions.size + } + + values(): IterableIterator { + return this.sessions.values() + } + + add(session: AiVaultSession): void { + const key = sessionAliasKey(session) + const index = this.nextIndex++ + if (key && !this.admit(session, key, index)) { + return + } + this.sessions.set(index, session) + } + + /** Whether the row is retained; a losing alias is dropped. */ + private admit(session: AiVaultSession, key: string, index: number): boolean { + const bucket = this.winnersBySessionId.get(session.sessionId) + if (bucket instanceof Map) { + const winner = this.contest(bucket.get(key), session, index) + if (winner) { + bucket.set(key, winner) + } + return winner !== null + } + const bucketKey = bucket && sessionAliasKey(bucket.session) + if (bucket && bucketKey && bucketKey !== key) { + this.winnersBySessionId.set( + session.sessionId, + new Map([ + [bucketKey, bucket], + [key, { session, indices: index }] + ]) + ) + return true + } + const winner = this.contest(bucket, session, index) + if (winner) { + this.winnersBySessionId.set(session.sessionId, winner) + } + return winner !== null + } + + /** The alias key's winner after this row, or null when the row loses. */ + private contest( + best: SessionWinner | undefined, + session: AiVaultSession, + index: number + ): SessionWinner | null { + if (!best) { + return { session, indices: index } + } + if (best.session === session) { + // The batch filter retains every occurrence of the winning object. + if (typeof best.indices === 'number') { + best.indices = [best.indices, index] + } else { + best.indices.push(index) + } + return best + } + if (!sessionAliasBeats(session, best.session)) { + return null + } + if (typeof best.indices === 'number') { + this.sessions.delete(best.indices) + } else { + for (const previousIndex of best.indices) { + this.sessions.delete(previousIndex) + } + } + return { session, indices: index } + } +} diff --git a/src/main/ai-vault/session-scan-cutoff.test.ts b/src/main/ai-vault/session-scan-cutoff.test.ts index 53ddd0a019b..304078ed5f5 100644 --- a/src/main/ai-vault/session-scan-cutoff.test.ts +++ b/src/main/ai-vault/session-scan-cutoff.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { CodexSessionCollection } from './codex-session-root-dedup' +import { ScannedSessionCollection } from './session-root-dedup' import { canStopParsingSessions } from './session-scan-cutoff' import { createAccumulator, finalizeSession, sessionSortTime } from './session-scanner-accumulator' @@ -23,8 +23,8 @@ function session(time: number | string, overrides: Partial = {}) }) } -function collection(rows: AiVaultSession[]): CodexSessionCollection { - const result = new CodexSessionCollection() +function collection(rows: AiVaultSession[]): ScannedSessionCollection { + const result = new ScannedSessionCollection() rows.forEach((row) => result.add(row)) return result } diff --git a/src/main/ai-vault/session-scan-cutoff.ts b/src/main/ai-vault/session-scan-cutoff.ts index e93d25bc985..980d16c6964 100644 --- a/src/main/ai-vault/session-scan-cutoff.ts +++ b/src/main/ai-vault/session-scan-cutoff.ts @@ -1,7 +1,7 @@ -import type { CodexSessionCollection } from './codex-session-root-dedup' +import type { ScannedSessionCollection } from './session-root-dedup' import { sessionSortTime } from './session-scanner-accumulator' -type ScanSessions = Pick +type ScanSessions = Pick function sortedCutoffIsNewer( times: number[], diff --git a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts index a75a437611a..b2ea6f0d4d2 100644 --- a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts +++ b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts @@ -53,7 +53,23 @@ const CASES = [ envVar: 'DEVIN_HOME', absolute: '/srv/devin', absoluteRoot: join('/srv/devin', 'transcripts'), - defaultRoot: () => join(homedir(), '.local', 'share', 'devin', 'cli', 'transcripts') + // Mirrors the platform-aware default in session-scanner-agent-sources.ts: + // %APPDATA%\devin\cli on Windows, $XDG_DATA_HOME/devin/cli elsewhere. + defaultRoot: () => + join( + process.platform === 'win32' + ? join( + process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming'), + 'devin', + 'cli' + ) + : join( + process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share'), + 'devin', + 'cli' + ), + 'transcripts' + ) }, { agent: 'openclaw', @@ -84,10 +100,51 @@ describe('agent scan roots from environment overrides', () => { vi.resetModules() }) + it('finds Devin transcripts in a relocated platform data directory', async () => { + const dataDir = join(homedir(), 'relocated-agent-data') + const roots = await rootDirsFor('devin', { + DEVIN_HOME: '', + [process.platform === 'win32' ? 'APPDATA' : 'XDG_DATA_HOME']: dataDir + }) + expect(roots).toEqual( + ['transcripts', 'agent_logs'].map((dir) => join(dataDir, 'devin', 'cli', dir)) + ) + }) + + it('finds Devin transcripts when the platform data variable is empty', async () => { + const roots = await rootDirsFor('devin', { + DEVIN_HOME: '', + APPDATA: '', + XDG_DATA_HOME: '' + }) + const dataDir = + process.platform === 'win32' + ? join(homedir(), 'AppData', 'Roaming') + : join(homedir(), '.local', 'share') + expect(roots).toEqual( + ['transcripts', 'agent_logs'].map((dir) => join(dataDir, 'devin', 'cli', dir)) + ) + }) + + it('keeps an explicit transcript root isolated and discovers both WSL layouts', async () => { + const { AI_VAULT_AGENT_SOURCES } = await import('./session-scanner-agent-sources') + const custom = join(homedir(), 'custom-devin') + const wslHome = join(homedir(), 'wsl-home') + expect( + AI_VAULT_AGENT_SOURCES.devin?.rootDirs({ devinTranscriptsDir: custom }, [wslHome]) + ).toEqual([ + custom, + join(wslHome, '.local', 'share', 'devin', 'cli', 'transcripts'), + join(wslHome, '.local', 'share', 'devin', 'cli', 'agent_logs') + ]) + }) + for (const testCase of CASES) { describe(testCase.envVar, () => { it('uses an absolute override', async () => { - const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: testCase.absolute }) + const roots = await rootDirsFor(testCase.agent, { + [testCase.envVar]: testCase.absolute + }) expect(roots[0]).toBe(testCase.absoluteRoot) }) @@ -99,14 +156,18 @@ describe('agent scan roots from environment overrides', () => { }) it.each(RELATIVE_VALUES)('falls back to the default root for %j', async (value) => { - const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: value }) + const roots = await rootDirsFor(testCase.agent, { + [testCase.envVar]: value + }) expect(roots[0]).toBe(testCase.defaultRoot()) }) // A relative root is the actual #13082 failure: it resolves against whichever Orca process // reads it, so the walk starts somewhere arbitrary and has no depth, entry or time cap. it.each(RELATIVE_VALUES)('never yields a relative root for %j', async (value) => { - const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: value }) + const roots = await rootDirsFor(testCase.agent, { + [testCase.envVar]: value + }) for (const root of roots) { expect(root).toBe(join(root)) expect(root.startsWith('/') || /^[A-Za-z]:[\\/]/.test(root)).toBe(true) diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index e2f4b59e39f..bd8f83af9e1 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -10,6 +10,7 @@ import { isClineSessionMetadataPath } from './session-scanner-cline-parser' import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta' +import { devinSessionsDbDependencyPath } from './session-scanner-devin-db' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { @@ -46,11 +47,18 @@ const PI_SESSIONS_DIR = normalizeAgentSessionsDir( // dedicated sessions-root override, so resolution differs from Pi/OMP in shape // as well as in variable name. const PRIME_AGENT_SESSIONS_DIR = primeAgentSessionsDirFromEnv() -// Why: Devin ATIF transcripts are stored under /transcripts. +// Why: Devin ATIF transcripts live under /transcripts; the cli +// data dir is %APPDATA%\devin\cli on Windows, $XDG_DATA_HOME/devin/cli elsewhere. const DEVIN_TRANSCRIPTS_DIR = join( resolveAbsoluteDirOverride( process.env.DEVIN_HOME, - join(homedir(), '.local', 'share', 'devin', 'cli') + process.platform === 'win32' + ? join(process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming'), 'devin', 'cli') + : join( + process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share'), + 'devin', + 'cli' + ) ), 'transcripts' ) @@ -94,7 +102,10 @@ type AiVaultAgentSourceTable = Record export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { claude: { rootDirs: (options, wslHomeDirs) => - claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, wslHomeDirs }), + claudeProjectsRootDirs({ + claudeProjectsDir: options.claudeProjectsDir, + wslHomeDirs + }), extensions: ['.jsonl'], // Why: Task subagent transcripts under `/subagents/` share the parent // sessionId and aren't independently resumable, so they'd just duplicate the @@ -152,15 +163,26 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { filePredicate: (filePath) => basename(filePath) === 'summary.json' }, devin: { - rootDirs: (options, wslHomeDirs) => - sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ + rootDirs: (options, wslHomeDirs) => [ + ...sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ '.local', 'share', 'devin', 'cli', 'transcripts' ]), - extensions: ['.json'] + // Devin 3000.10.31 exports ATIF to agent_logs by default. + ...(options.devinTranscriptsDir ? [] : [join(dirname(DEVIN_TRANSCRIPTS_DIR), 'agent_logs')]), + ...wslHomeDirs.map((homeDir) => + join(homeDir, '.local', 'share', 'devin', 'cli', 'agent_logs') + ) + ], + mergeRootDiscoveries: true, + extensions: ['.json'], + // Why: one sessions.db indexes the whole transcripts dir from beside it; + // tracking its stat lets a db-only change (title edit, hide) re-merge + // sessions without re-reading any transcript. + contentDependencyPath: devinSessionsDbDependencyPath }, hermes: { rootDirs: (options, wslHomeDirs) => diff --git a/src/main/ai-vault/session-scanner-devin-contention.test.ts b/src/main/ai-vault/session-scanner-devin-contention.test.ts new file mode 100644 index 00000000000..b2c101dbff1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-contention.test.ts @@ -0,0 +1,55 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import { scanAiVaultSessions } from './session-scanner' +import { resetDevinSessionsIndexCacheForTests } from './session-scanner-devin-db' +import * as databaseReader from './session-scanner-opencode-sqlite-open' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import { isolatedScanRoots } from './session-scanner-test-fixtures' + +let root: string | undefined + +afterEach(async () => { + vi.restoreAllMocks() + resetSessionParseCacheForTests() + resetDevinSessionsIndexCacheForTests() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('attempts a contended index once per scan and enriches all transcripts after recovery', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-devin-contention-')) + const roots = isolatedScanRoots(root) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) + const db = new SyncDatabase(join(root, 'sessions.db')) + const cwd = join(root, 'workspace') + try { + db.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT)') + for (const id of ['one', 'two', 'three']) { + db.prepare('INSERT INTO sessions VALUES (?, ?)').run(id, cwd) + await writeFile( + join(roots.devinTranscriptsDir, `${id}.json`), + JSON.stringify({ session_id: id, steps: [{ source: 'user', message: `Task ${id}` }] }) + ) + } + } finally { + db.close() + } + const reader = vi.spyOn(databaseReader, 'readOpenCodeDatabase').mockImplementation(() => { + throw new Error('SQLITE_BUSY') + }) + const first = await scanAiVaultSessions({ ...roots, unlimited: true }) + expect(first.sessions).toHaveLength(3) + expect(first.sessions.every((session) => session.cwd === null)).toBe(true) + expect(reader).toHaveBeenCalledTimes(1) + + reader.mockRestore() + const recoveredReader = vi.spyOn(databaseReader, 'readOpenCodeDatabase') + const recovered = await scanAiVaultSessions({ ...roots, unlimited: true }) + expect(recovered.sessions).toHaveLength(3) + expect(recovered.sessions.every((session) => session.cwd === cwd)).toBe(true) + expect(recoveredReader).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/ai-vault/session-scanner-devin-db.test.ts b/src/main/ai-vault/session-scanner-devin-db.test.ts new file mode 100644 index 00000000000..d80dab41879 --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-db.test.ts @@ -0,0 +1,468 @@ +import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import { scanAiVaultSessions } from './session-scanner' +import { + devinSessionsDbDependencyPath, + devinSessionsDbPath, + devinSessionsIndexForSidecar, + resetDevinSessionsIndexCacheForTests +} from './session-scanner-devin-db' +import { parseDevinSessionContent } from './session-scanner-devin-parser' +import { enrichSessionFromSidecar } from './session-scanner-sidecar-enrichment' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' +import type { SessionSidecarStat } from './session-sidecar-stat' + +// The Devin CLI sessions.db schema (3000.10.x). Written out in full rather +// than trimmed, because the reader probes every column it names. +const DEVIN_SESSIONS_SCHEMA = ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + main_chain_id TEXT, + shell_last_seen_index INTEGER, + cogs_json TEXT, + workspace_dirs TEXT, + hidden INTEGER, + metadata TEXT + ); +` + +const DEVIN_CREATED_S = 1_777_000_000 +const DEVIN_ACTIVITY_S = 1_777_003_600 + +let tempDirs: string[] = [] + +afterEach(async () => { + resetDevinSessionsIndexCacheForTests() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +type DevinDbRow = { + id: string + working_directory?: string | null + model?: string | null + title?: string | null + created_at?: number | null + last_activity_at?: number | null + hidden?: number | null +} + +function writeDevinSessionsDb(dbPath: string, rows: readonly DevinDbRow[]): void { + const db = new SyncDatabase(dbPath) + try { + db.exec(DEVIN_SESSIONS_SCHEMA) + db.exec('DELETE FROM sessions') + const insert = db.prepare( + `INSERT INTO sessions (id, working_directory, model, title, created_at, last_activity_at, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + for (const row of rows) { + insert.run( + row.id, + row.working_directory ?? null, + row.model ?? null, + row.title ?? null, + row.created_at ?? null, + row.last_activity_at ?? null, + row.hidden ?? 0 + ) + } + } finally { + db.close() + } +} + +async function sidecarOf(dbPath: string): Promise { + const fileStat = await stat(dbPath) + return { path: dbPath, mtimeMs: fileStat.mtimeMs, sizeBytes: fileStat.size } +} + +function devinCandidate(filePath: string, sidecar: FileWithMtime['sidecar']): SessionFileCandidate { + return { + agent: 'devin', + file: { + path: filePath, + mtimeMs: 1, + modifiedAt: new Date(1).toISOString(), + sidecar + }, + codexHome: null + } +} + +function devinFoldSession(filePath: string, record: Record) { + return parseDevinSessionContent( + { path: filePath, mtimeMs: 1, modifiedAt: new Date(1).toISOString() }, + JSON.stringify(record), + 'linux' + ) +} + +describe('devinSessionsDbPath', () => { + it('names the sessions.db beside the transcripts dir', () => { + expect(devinSessionsDbPath(join('cli', 'transcripts', 'apricot-houseboat.json'))).toBe( + join('cli', 'sessions.db') + ) + }) +}) + +describe('devinSessionsDbDependencyPath', () => { + it('points at sessions.db until a wal file appears beside it', async () => { + const dir = await tempDir('orca-devin-db-') + const cliDir = join(dir, 'cli') + const transcriptPath = join(cliDir, 'transcripts', 'apricot.json') + const dbPath = join(cliDir, 'sessions.db') + expect(await devinSessionsDbDependencyPath(transcriptPath)).toBe(dbPath) + await mkdir(cliDir, { recursive: true }) + await writeFile(`${dbPath}-wal`, 'wal bytes') + expect(await devinSessionsDbDependencyPath(transcriptPath)).toBe(`${dbPath}-wal`) + }) +}) + +describe('devinSessionsIndexForSidecar', () => { + it('reads rows keyed by session id with unix seconds as ISO strings', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + writeDevinSessionsDb(dbPath, [ + { + id: 'apricot-houseboat', + working_directory: 'D:\\work\\orca', + model: 'swe-1-6-fast', + title: 'Fix the vault', + created_at: DEVIN_CREATED_S, + last_activity_at: DEVIN_ACTIVITY_S, + hidden: 0 + }, + { id: 'hidden-one', hidden: 1 } + ]) + + const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath)) + expect(unreadable).toBe(false) + const row = index?.get('apricot-houseboat') + expect(row).toEqual({ + workingDirectory: 'D:\\work\\orca', + model: 'swe-1-6-fast', + title: 'Fix the vault', + createdAt: new Date(DEVIN_CREATED_S * 1000).toISOString(), + lastActivityAt: new Date(DEVIN_ACTIVITY_S * 1000).toISOString(), + hidden: false + }) + expect(index?.get('hidden-one')?.hidden).toBe(true) + }) + + it('returns no index when discovery observed no db', () => { + expect(devinSessionsIndexForSidecar('none').index).toBeNull() + expect(devinSessionsIndexForSidecar(undefined).index).toBeNull() + }) + + it('marks a stat-refused db unreadable without opening it', () => { + const { index, unreadable } = devinSessionsIndexForSidecar('unknown') + expect(index).toBeNull() + expect(unreadable).toBe(true) + }) + + it('yields an empty index when the sessions table is absent', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + const db = new SyncDatabase(dbPath) + try { + db.exec('CREATE TABLE other (id TEXT)') + } finally { + db.close() + } + const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath)) + expect(unreadable).toBe(false) + expect(index?.size).toBe(0) + }) + + it('tolerates an older schema missing optional columns', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + const db = new SyncDatabase(dbPath) + try { + db.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT)') + db.prepare('INSERT INTO sessions (id, working_directory) VALUES (?, ?)').run( + 'old-session', + '/srv/old' + ) + } finally { + db.close() + } + const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath)) + expect(unreadable).toBe(false) + expect(index?.get('old-session')).toEqual({ + workingDirectory: '/srv/old', + model: null, + title: null, + createdAt: null, + lastActivityAt: null, + hidden: false + }) + }) + + it('reports a corrupt db as unreadable rather than throwing', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + await writeFile(dbPath, 'this is not sqlite') + const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath)) + expect(index).toBeNull() + expect(unreadable).toBe(true) + }) + + it('invalidates when the observed file changes from db to wal with identical stats', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'old' }]) + const observation = await sidecarOf(dbPath) + expect(devinSessionsIndexForSidecar(observation).index?.get('apricot')?.title).toBe('old') + writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'new' }]) + const updated = devinSessionsIndexForSidecar({ ...observation, path: `${dbPath}-wal` }) + expect(updated.index?.get('apricot')?.title).toBe('new') + }) + + it('opens the db for a wal observation and re-reads when the wal stat moves', async () => { + const dir = await tempDir('orca-devin-db-') + const dbPath = join(dir, 'sessions.db') + const walPath = `${dbPath}-wal` + writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'old' }]) + const dbStatBefore = await stat(dbPath) + await writeFile(walPath, 'wal-v1') + + const first = devinSessionsIndexForSidecar(await sidecarOf(walPath)) + expect(first.unreadable).toBe(false) + expect(first.index?.get('apricot')?.title).toBe('old') + + // A wal-mode write can leave the db stat untouched; restore it so only + // the wal observation differs between reads. + writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'new' }]) + await utimes(dbPath, dbStatBefore.atimeMs / 1000, dbStatBefore.mtimeMs / 1000) + await writeFile(walPath, 'wal-v2-longer') + + const second = devinSessionsIndexForSidecar(await sidecarOf(walPath)) + expect(second.unreadable).toBe(false) + expect(second.index?.get('apricot')?.title).toBe('new') + }) +}) + +describe('enrichSessionFromSidecar for devin', () => { + it('fills cwd, generated title, model and timestamps from the db row', async () => { + const dir = await tempDir('orca-devin-db-') + const cliDir = join(dir, 'cli') + const transcriptsDir = join(cliDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, 'apricot.json') + const dbPath = join(cliDir, 'sessions.db') + writeDevinSessionsDb(dbPath, [ + { + id: 'apricot', + working_directory: '/srv/work', + model: 'swe-1-6-fast', + title: 'Db title', + created_at: DEVIN_CREATED_S, + last_activity_at: DEVIN_ACTIVITY_S + } + ]) + + const fold = devinFoldSession(filePath, { + session_id: 'apricot', + steps: [] + }) + expect(fold?.cwd).toBeNull() + const { session, refused } = await enrichSessionFromSidecar( + devinCandidate(filePath, await sidecarOf(dbPath)), + fold, + 'linux' + ) + expect(refused).toBe(false) + expect(session?.cwd).toBe('/srv/work') + expect(session?.title).toBe('Db title') + expect(session?.model).toBe('swe-1-6-fast') + expect(session?.createdAt).toBe(new Date(DEVIN_CREATED_S * 1000).toISOString()) + expect(session?.updatedAt).toBe(new Date(DEVIN_ACTIVITY_S * 1000).toISOString()) + expect(session?.resumeCommand).toContain('/srv/work') + }) + + it('keeps transcript-derived fields over the db row', async () => { + const dir = await tempDir('orca-devin-db-') + const cliDir = join(dir, 'cli') + const transcriptsDir = join(cliDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, 'apricot.json') + const dbPath = join(cliDir, 'sessions.db') + writeDevinSessionsDb(dbPath, [ + { + id: 'apricot', + working_directory: '/srv/db-cwd', + model: 'db-model', + title: 'Db title', + created_at: DEVIN_CREATED_S + } + ]) + + const fold = devinFoldSession(filePath, { + session_id: 'apricot', + working_directory: '/srv/transcript-cwd', + agent: { model_name: 'transcript-model' }, + steps: [ + { + metadata: { + created_at: '2026-05-01T10:00:00.000Z', + is_user_input: true + }, + text: 'Transcript title' + } + ] + }) + const { session } = await enrichSessionFromSidecar( + devinCandidate(filePath, await sidecarOf(dbPath)), + fold, + 'linux' + ) + expect(session?.cwd).toBe('/srv/transcript-cwd') + expect(session?.title).toBe('Transcript title') + expect(session?.model).toBe('transcript-model') + expect(session?.createdAt).toBe('2026-05-01T10:00:00.000Z') + }) + + it('drops a session the user hid in Devin', async () => { + const dir = await tempDir('orca-devin-db-') + const cliDir = join(dir, 'cli') + const transcriptsDir = join(cliDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, 'apricot.json') + const dbPath = join(cliDir, 'sessions.db') + writeDevinSessionsDb(dbPath, [{ id: 'apricot', hidden: 1 }]) + + const fold = devinFoldSession(filePath, { + session_id: 'apricot', + steps: [] + }) + const { session, refused } = await enrichSessionFromSidecar( + devinCandidate(filePath, await sidecarOf(dbPath)), + fold, + 'linux' + ) + expect(session).toBeNull() + expect(refused).toBe(false) + }) +}) + +describe('devin sessions.db through the scan', () => { + async function writeDevinVault( + dir: string, + directory = 'transcripts' + ): Promise<{ transcriptsDir: string; dbPath: string }> { + const cliDir = join(dir, 'devin-cli') + const transcriptsDir = join(cliDir, directory) + await mkdir(transcriptsDir, { recursive: true }) + await writeFile( + join(transcriptsDir, 'devin-shown.json'), + JSON.stringify({ session_id: 'shown', steps: [] }) + ) + await writeFile( + join(transcriptsDir, 'hidden.json'), + JSON.stringify({ session_id: 'hidden', steps: [] }) + ) + const dbPath = join(cliDir, 'sessions.db') + writeDevinSessionsDb(dbPath, [ + { + id: 'shown', + working_directory: '/srv/shown', + title: 'Shown session', + model: 'swe-1-6-fast', + created_at: DEVIN_CREATED_S, + last_activity_at: DEVIN_ACTIVITY_S + }, + { id: 'hidden', hidden: 1 } + ]) + return { transcriptsDir, dbPath } + } + + it.each(['transcripts', 'agent_logs'])( + 'enriches %s sessions by session_id and excludes hidden ones', + async (directory) => { + const root = await tempDir('orca-devin-scan-') + const { transcriptsDir } = await writeDevinVault(root, directory) + const result = await scanAiVaultSessions({ + ...isolatedScanRoots(root), + devinTranscriptsDir: transcriptsDir + }) + const devin = result.sessions.filter((session) => session.agent === 'devin') + expect(devin.map((session) => session.sessionId)).toEqual(['shown']) + expect(devin[0]?.cwd).toBe('/srv/shown') + expect(devin[0]?.title).toBe('Shown session') + expect(devin[0]?.model).toBe('swe-1-6-fast') + expect(devin[0]?.createdAt).toBe(new Date(DEVIN_CREATED_S * 1000).toISOString()) + } + ) + + it('still lists sessions when sessions.db is absent', async () => { + const root = await tempDir('orca-devin-scan-') + const transcriptsDir = join(root, 'devin-cli', 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + await writeFile( + join(transcriptsDir, 'bare.json'), + JSON.stringify({ + session_id: 'bare', + working_directory: '/srv/bare', + steps: [] + }) + ) + const result = await scanAiVaultSessions({ + ...isolatedScanRoots(root), + devinTranscriptsDir: transcriptsDir + }) + const devin = result.sessions.filter((session) => session.agent === 'devin') + expect(devin.map((session) => session.sessionId)).toEqual(['bare']) + expect(devin[0]?.cwd).toBe('/srv/bare') + }) + + it('picks up db-only changes on a rescan without the transcript moving', async () => { + const root = await tempDir('orca-devin-scan-') + const { transcriptsDir, dbPath } = await writeDevinVault(root) + const options = { + ...isolatedScanRoots(root), + devinTranscriptsDir: transcriptsDir + } + await scanAiVaultSessions(options) + + // Unhide and retitle; bump mtime so the sidecar stat reads as changed even + // on a coarse-granularity filesystem. + writeDevinSessionsDb(dbPath, [ + { id: 'shown', working_directory: '/srv/moved', title: 'Retitled' }, + { + id: 'hidden', + hidden: 0, + working_directory: '/srv/unhidden', + title: 'Back again' + } + ]) + const bumped = Date.now() + 10_000 + await utimes(dbPath, new Date(bumped), new Date(bumped)) + + const result = await scanAiVaultSessions(options) + const devin = result.sessions.filter((session) => session.agent === 'devin') + const byId = new Map(devin.map((session) => [session.sessionId, session])) + expect(byId.get('shown')?.cwd).toBe('/srv/moved') + expect(byId.get('shown')?.title).toBe('Retitled') + expect(byId.get('hidden')?.cwd).toBe('/srv/unhidden') + expect(byId.get('hidden')?.title).toBe('Back again') + }) +}) diff --git a/src/main/ai-vault/session-scanner-devin-db.ts b/src/main/ai-vault/session-scanner-devin-db.ts new file mode 100644 index 00000000000..e603b6303cb --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-db.ts @@ -0,0 +1,205 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { dirname, join } from 'node:path' +import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' +import type { SessionSidecarObservation } from './session-sidecar-stat' +import { asRecord } from './session-scanner-record-value' +import { numberValue } from './session-scanner-token-values' +import { extractString } from './session-scanner-values' + +// Why: Devin CLI keeps a tiny `sessions` table in sessions.db beside the +// transcripts dir — the transcript holds no cwd on Windows installs, so the db +// is what lets a Devin session group under a workspace. One row per session_id +// (the transcript filename), timestamps in unix SECONDS. + +export type DevinSessionIndexRow = { + workingDirectory: string | null + title: string | null + model: string | null + createdAt: string | null + lastActivityAt: string | null + // The user hid the session in Devin's own UI; the listing honors that. + hidden: boolean +} + +export type DevinSessionsIndex = Map + +// Optional in older schemas; `id` is the only required column. +const DEVIN_SESSION_TABLE = 'sessions' +const DEVIN_SESSION_OPTIONAL_COLUMNS = [ + 'working_directory', + 'title', + 'model', + 'created_at', + 'last_activity_at', + 'hidden' +] as const + +// One index per observed db stat, so all transcripts under a root share a +// single open per scan and a db the transcript mtimes cannot see still +// re-merges when its own stat moves. +const INDEX_CACHE_LIMIT = 8 +const indexCache = new Map< + string, + { sidecarPath: string; mtimeMs: number; sizeBytes: number; index: DevinSessionsIndex } +>() +const scanDbFailures = new AsyncLocalStorage>() + +/** A contended database must cost one timeout per scan, not one per transcript. */ +export function withDevinSessionsDbScan(fn: () => Promise): Promise { + return scanDbFailures.run(new Set(), fn) +} + +/** + * The sessions.db a transcript's index lives in: it sits beside the + * transcripts dir, never inside it. Transcripts are flat files directly under + * the root, so the db is always dirname(dirname(file)). + * @param transcriptFilePath - A discovered Devin transcript path. + * @returns Absolute path to the sibling sessions.db. + */ +export function devinSessionsDbPath(transcriptFilePath: string): string { + return join(dirname(dirname(transcriptFilePath)), 'sessions.db') +} + +/** + * The file whose stat should drive re-enrichment. In WAL mode, committed rows + * sit in sessions.db-wal while sessions.db keeps its stat until checkpoint, so + * a present wal is the fresher signal; a clean-close db has no wal and its own + * stat carries the change. + * @param transcriptFilePath - A discovered Devin transcript path. + * @returns Absolute path to sessions.db-wal when it exists, else sessions.db. + */ +export async function devinSessionsDbDependencyPath(transcriptFilePath: string): Promise { + const dbPath = devinSessionsDbPath(transcriptFilePath) + const walPath = `${dbPath}-wal` + try { + await wslGatedStat(walPath, 'scan') + return walPath + } catch { + // Missing wal is the common case; a refused probe degrades to watching + // the db itself rather than taking the transcript down with it. + return dbPath + } +} + +/** + * The db a dependency observation actually belongs to: the sidecar may point + * at sessions.db-wal, but SQLite is always opened on sessions.db itself. + */ +function devinSessionsDbPathForSidecarPath(sidecarPath: string): string { + return sidecarPath.endsWith('-wal') ? sidecarPath.slice(0, -'-wal'.length) : sidecarPath +} + +/** + * The sessions.db index for a discovery-observed sidecar, or why there is + * none. Never throws: a missing/db-less root is `index: null`, an observed db + * that could not be read is `unreadable` (so the caller records the sidecar as + * unknown and retries next scan rather than caching un-enriched results). + * @param sidecar - The file's sidecar observation from discovery. + */ +export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation | undefined): { + index: DevinSessionsIndex | null + unreadable: boolean +} { + if (sidecar === undefined || sidecar === 'none') { + return { index: null, unreadable: false } + } + if (sidecar === 'unknown') { + // The stat already failed this scan; an open would ride the same stalled + // share. Retry next scan instead of paying it per transcript. + return { index: null, unreadable: true } + } + const dbPath = devinSessionsDbPathForSidecarPath(sidecar.path) + const failures = scanDbFailures.getStore() + if (failures?.has(dbPath)) { + return { index: null, unreadable: true } + } + const cached = indexCache.get(dbPath) + if ( + cached && + cached.sidecarPath === sidecar.path && + cached.mtimeMs === sidecar.mtimeMs && + cached.sizeBytes === sidecar.sizeBytes + ) { + indexCache.delete(dbPath) + indexCache.set(dbPath, cached) + return { index: cached.index, unreadable: false } + } + try { + const index = readDevinSessionsIndex(dbPath) + if (indexCache.size >= INDEX_CACHE_LIMIT) { + const oldest = indexCache.keys().next().value + if (oldest !== undefined) { + indexCache.delete(oldest) + } + } + indexCache.set(dbPath, { + sidecarPath: sidecar.path, + mtimeMs: sidecar.mtimeMs, + sizeBytes: sidecar.sizeBytes, + index + }) + return { index, unreadable: false } + } catch { + // Retry next scan even if the database stat has not changed. + failures?.add(dbPath) + return { index: null, unreadable: true } + } +} + +export function resetDevinSessionsIndexCacheForTests(): void { + indexCache.clear() +} + +/** + * Read every session row from a Devin sessions.db. Read-only plus query_only, + * same open policy as the OpenCode db (which also picks the busy timeout: 0 + * over a WSL share, where SQLite locks can never be taken). Older schemas + * missing optional columns still yield rows; a db without the sessions table + * or its id column yields an empty index. + * @param dbPath - Absolute path to a sessions.db file. + * @returns Rows keyed by session id; rethrows whatever SQLite raised. + */ +function readDevinSessionsIndex(dbPath: string): DevinSessionsIndex { + return readOpenCodeDatabase({ + dbPath, + read: (db) => { + const index: DevinSessionsIndex = new Map() + if (!tableExists(db, DEVIN_SESSION_TABLE) || !columnExists(db, DEVIN_SESSION_TABLE, 'id')) { + return index + } + const columns = DEVIN_SESSION_OPTIONAL_COLUMNS.filter((column) => + columnExists(db, DEVIN_SESSION_TABLE, column) + ) + const statement = db.prepare( + `SELECT id${columns.map((column) => `, ${column}`).join('')} FROM ${DEVIN_SESSION_TABLE}` + ) + for (const row of statement.all()) { + const record = asRecord(row) + const id = record ? extractString(record.id) : null + if (!record || !id) { + continue + } + index.set(id, { + workingDirectory: extractString(record.working_directory), + title: extractString(record.title), + model: extractString(record.model), + createdAt: unixSecondsToIso(record.created_at), + lastActivityAt: unixSecondsToIso(record.last_activity_at), + hidden: numberValue(record.hidden) !== 0 + }) + } + return index + } + }) +} + +function unixSecondsToIso(value: unknown): string | null { + const seconds = numberValue(value) + if (seconds <= 0) { + return null + } + const date = new Date(seconds * 1000) + return Number.isFinite(date.getTime()) ? date.toISOString() : null +} diff --git a/src/main/ai-vault/session-scanner-devin-dedup.test.ts b/src/main/ai-vault/session-scanner-devin-dedup.test.ts new file mode 100644 index 00000000000..0af43651a39 --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-dedup.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import { parseDevinSessionContent } from './session-scanner-devin-parser' +import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup' + +const roots: string[] = [] +afterEach(async () => { + vi.unstubAllEnvs() + vi.resetModules() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function transcript(sessionId: string, timestamp = '2026-09-19T00:00:00Z'): string { + return JSON.stringify({ + schema_version: 'ATIF-v1.7', + session_id: sessionId, + steps: [{ source: 'user', message: sessionId, timestamp }] + }) +} + +it('lists a session in both default directories once and still fills the scan limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'devin-dedup-')) + roots.push(root) + vi.stubEnv('DEVIN_HOME', root) + vi.resetModules() + for (const dir of ['transcripts', 'agent_logs']) { + await mkdir(join(root, dir)) + await writeFile(join(root, dir, `${dir}-same.json`), transcript('same')) + } + await writeFile( + join(root, 'transcripts', 'other.json'), + transcript('other', '2026-09-18T00:00:00Z') + ) + const { scanAiVaultSessions } = await import('./session-scanner') + const { devinTranscriptsDir: _unused, ...options } = isolatedScanRoots(root) + const result = await scanAiVaultSessions({ ...options, limit: 2 }) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) + const unlimited = await scanAiVaultSessions({ ...options, unlimited: true }) + expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) +}) + +it.each(['win32-x64', 'linux-x64'] as const)( + 'deduplicates both remote directories on %s', + async (platform) => { + const provider = new MemoryRemoteProvider() + const windows = platform === 'win32-x64' + const home = windows ? 'C:/Users/ada' : '/home/ada' + const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli` + provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10) + provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20) + provider.addFile( + `${cliDir}/transcripts/other.json`, + transcript('other', '2026-09-18T00:00:00Z'), + 5 + ) + const result = await scanRemoteAiVaultSessions({ + provider, + remoteHome: home, + executionHostId: 'ssh:devin', + hostPlatform: getRemoteHostPlatform(platform), + limit: 2 + }) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) + expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`) + } +) + +function session(path: string) { + const parsed = parseDevinSessionContent( + { path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() }, + transcript('same'), + 'win32' + ) + if (!parsed) { + throw new Error('Devin fixture did not parse') + } + return parsed +} + +it('keeps the newest export, prefers agent_logs on ties, and isolates execution hosts and installs', () => { + const older = session('C:/Users/ada/devin/transcripts/same.json') + const current = session('C:/Users/ada/devin/agent_logs/devin-same.json') + const newer = { ...older, modifiedAt: '2026-09-20T00:00:00Z' } + const remote = { ...current, executionHostId: 'ssh:other' as const } + const wsl = session('\\\\wsl$\\Ubuntu\\home\\ada\\devin\\agent_logs\\devin-same.json') + const otherInstall = session('C:/Users/other/devin/agent_logs/devin-same.json') + for (const rows of [ + [older, current], + [current, older] + ]) { + expect(dedupeScannedSessions(rows)).toEqual([current]) + } + const rows = [older, current, remote, wsl, otherInstall, newer] + const expected = [remote, wsl, otherInstall, newer] + expect(dedupeScannedSessions(rows)).toEqual(expected) + const collection = new ScannedSessionCollection() + for (const row of rows) { + collection.add(row) + } + expect([...collection.values()]).toEqual(expected) + expect(collection.size).toBe(4) +}) diff --git a/src/main/ai-vault/session-scanner-devin-parser.test.ts b/src/main/ai-vault/session-scanner-devin-parser.test.ts index 113aac7a9ea..0d0d91659b3 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.test.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.test.ts @@ -92,4 +92,122 @@ describe('parseDevinSessionFile', () => { text: 'Done' }) }) + + it('extracts text from an array-valued ATIF message', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-')) + tempDirs.push(dir) + const path = join(dir, 'array-message.json') + const mtimeMs = Date.now() + await writeFile( + path, + JSON.stringify({ + session_id: 'array-message', + agent: {}, + steps: [ + { + timestamp: '2026-05-26T00:00:00Z', + source: 'user', + message: [{ text: 'First part' }, { text: 'second part' }] + } + ] + }) + ) + + const session = await parseDevinSessionFile({ + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }) + + expect(session?.messageCount).toBe(1) + expect(session?.title).toBe('First part second part') + expect(session?.previewMessages[0]).toMatchObject({ + role: 'user', + text: 'First part second part' + }) + }) + + it('parses a real ATIF-v1.7 transcript', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-')) + tempDirs.push(dir) + const path = join(dir, 'apricot-houseboat.json') + const mtimeMs = Date.now() + await writeFile( + path, + JSON.stringify({ + schema_version: 'ATIF-v1.7', + session_id: 'apricot-houseboat', + agent: { + name: 'devin', + version: '3000.10.27', + model_name: 'SWE-2 High', + tool_definitions: [] + }, + steps: [ + { + step_id: 8, + timestamp: '2026-09-16T09:50:40.000000000+00:00', + source: 'system', + message: 'You are Devin, an AI software engineer.' + }, + { + step_id: 9, + timestamp: '2026-09-16T09:50:55.745112900+00:00', + source: 'user', + message: 'fix toàn bộ các lỗi này đi', + extra: { telemetry: { source: 'user', operation: 'unknown' } } + }, + { + step_id: 10, + timestamp: '2026-09-16T09:51:02.1+00:00', + source: 'agent', + message: '# Báo cáo\n\nĐã sửa xong.', + tool_calls: [], + model_name: 'swe-2-high', + metrics: { prompt_tokens: 174988, completion_tokens: 2061, cached_tokens: 170575 }, + extra: { + generation_model: 'swe-2-high', + telemetry: { source: 'assistant', operation: 'inference' } + } + }, + { + step_id: 11, + timestamp: '2026-09-16T09:51:30.0+00:00', + source: 'agent', + message: 'All checks pass.', + metrics: { prompt_tokens: 100, completion_tokens: 10, cached_tokens: 50 }, + extra: { telemetry: { source: 'assistant', operation: 'inference' } } + } + ], + final_metrics: { + total_prompt_tokens: 175088, + total_completion_tokens: 2071, + total_cached_tokens: 170625, + total_steps: 11 + } + }) + ) + + const session = await parseDevinSessionFile({ + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }) + + expect(session).not.toBeNull() + expect(session?.sessionId).toBe('apricot-houseboat') + expect(session?.model).toBe('SWE-2 High') + expect(session?.title).toBe('fix toàn bộ các lỗi này đi') + expect(session?.messageCount).toBe(3) + // prompt + completion per step; cached_tokens is already inside prompt_tokens. + expect(session?.totalTokens).toBe(177159) + expect(session?.updatedAt).toBe('2026-09-16T09:51:30.000Z') + expect(session?.previewMessages.length).toBeGreaterThan(0) + for (const preview of session?.previewMessages ?? []) { + expect(['user', 'assistant']).toContain(preview.role) + expect(preview.text).toBeTruthy() + } + expect(session?.previewMessages.some((preview) => preview.role === 'user')).toBe(true) + expect(session?.previewMessages.some((preview) => preview.role === 'assistant')).toBe(true) + }) }) diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index d3a62b08a59..ad1ff449187 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -78,40 +78,48 @@ function parseDevinSessionRecord( } function extractDevinStepText(step: Record): string | null { + // ATIF-v1.7 carries `message` as a plain string; older shapes wrap it as {content}. + const messageText = extractString(step.message) + if (messageText) { + return messageText + } const message = asRecord(step.message) if (message) { return extractContentText(message.content) ?? extractString(message.content) } - return extractString(step.text) + // ATIF also allows `message` as an array of content parts. + return extractContentText(step.message) ?? extractString(step.text) } +// Each bucket resolves from the first source that reports it. ATIF +// `prompt_tokens` already includes `cached_tokens`, so only the Claude-style +// cache keys (which sit outside input_tokens) are summed. function devinStepTokenTotal( metadata: Record | null, - metrics: Record | null + metrics: Record | null, + stepMetrics: Record | null ): number { + const sources = [metadata, metrics, stepMetrics] return ( - numberFromDevinMetadata(metadata, metrics, ['total_input_tokens', 'input_tokens']) + - numberFromDevinMetadata(metadata, metrics, ['output_tokens']) + - numberFromDevinMetadata(metadata, metrics, ['cache_read_tokens', 'cache_read_input_tokens']) + - numberFromDevinMetadata(metadata, metrics, [ - 'cache_creation_tokens', - 'cache_creation_input_tokens' - ]) + firstDevinMetricValue(sources, ['total_input_tokens', 'input_tokens', 'prompt_tokens']) + + firstDevinMetricValue(sources, ['output_tokens', 'completion_tokens']) + + firstDevinMetricValue(sources, ['cache_read_tokens', 'cache_read_input_tokens']) + + firstDevinMetricValue(sources, ['cache_creation_tokens', 'cache_creation_input_tokens']) ) } -function numberFromDevinMetadata( - metadata: Record | null, - metrics: Record | null, +function firstDevinMetricValue( + sources: readonly (Record | null)[], keys: readonly string[] ): number { - for (const source of [metadata, metrics]) { + for (const source of sources) { if (!source) { continue } for (const key of keys) { - const value = numberValue(source[key]) - if (value > 0) { + const rawValue = source[key] + const value = numberValue(rawValue) + if (value > 0 || (value === 0 && typeof rawValue === 'number' && Number.isFinite(rawValue))) { return value } } @@ -125,12 +133,23 @@ export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: u return } const metadata = asRecord(stepRecord.metadata) - updateTimeline(accumulator, extractString(metadata?.created_at)) + updateTimeline( + accumulator, + extractString(stepRecord.timestamp) ?? extractString(metadata?.created_at) + ) const metrics = asRecord(metadata?.metrics) + const extra = asRecord(stepRecord.extra) accumulator.model ??= - extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) - accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) - const isUser = metadata?.is_user_input === true + extractString(stepRecord.model_name) ?? + extractString(extra?.generation_model) ?? + extractString(metadata?.generation_model) ?? + extractString(metrics?.generation_model) + accumulator.totalTokens += devinStepTokenTotal(metadata, metrics, asRecord(stepRecord.metrics)) + // ATIF `source` is 'user' | 'agent' | 'system'; system steps are setup noise + // that must not count as messages or feed title/preview. + const source = extractString(stepRecord.source) + const isSystem = source === 'system' + const isUser = !isSystem && (source === 'user' || metadata?.is_user_input === true) if (isUser) { accumulator.messageCount++ const text = @@ -142,7 +161,10 @@ export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: u accumulator.title ??= titleCandidate } addPreviewContent(accumulator, 'user', text ?? stepRecord.content) - } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { + } else if ( + !isSystem && + (source === 'agent' || extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) + ) { accumulator.messageCount++ addPreviewContent( accumulator, diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 6ebb1a76a2b..95b50b139ce 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -89,7 +89,13 @@ export type SessionParseStats = TranscriptReadStats & { } export function createSessionParseStats(): SessionParseStats { - return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 } + return { + reused: 0, + incremental: 0, + fullParses: 0, + earlyStopped: 0, + bytesRead: 0 + } } /** @@ -209,17 +215,22 @@ async function parseCachedInLane( } const session = await readWholeTranscript({ candidate, platform, stats }) + // Whole-file agents merge the sibling here just like the resumable branch + // does post-read; the raw fold stays in foldSession so a sibling-only change + // re-merges without re-reading the transcript. + const enriched = await enrichSessionFromSidecar(candidate, session, platform) storeSessionParseCacheEntry(file.path, { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, platform, - session, - // A whole-file parse reads the sibling itself, so a change to it re-parses. - sidecar: file.sidecar, + session: enriched.session, + // A refused sibling keeps the transcript's own result cached; only the + // sibling is recorded as unknown, so the next scan re-merges. + sidecar: enriched.refused ? 'unknown' : file.sidecar, foldSession: session, resume: null }) - return session + return enriched.session } async function reuseCachedSession( diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index 88d393fcced..b04db9e0c92 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -92,6 +92,12 @@ describe('buildAiVaultServiceEnv', () => { expect(env.PATH).toBe('C:\\bin') }) + it('passes a relocated AppData through so Devin resolves its Windows data root', () => { + const env = buildAiVaultServiceEnv({ AppData: 'D:\\Roaming' }, 'win32') + + expect(env.APPDATA).toBe('D:\\Roaming') + }) + it('spells SystemRoot the way Windows Node expects', () => { expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe( 'C:\\Windows' diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index c6513434c8d..257ba676d8c 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -33,6 +33,9 @@ export const RUNTIME_ENV_ALLOWLIST = [ // Why: the desktop child resolves agent roots from its own environment, so // dropping one hides every session of a user who relocated that agent's home. const AGENT_ROOT_ENV_ALLOWLIST = [ + // Why: Devin's Windows data root resolves under %APPDATA%, which managed + // machines relocate away from the profile default. + 'APPDATA', 'CODEX_HOME', 'CLINE_SESSION_DATA_DIR', 'COPILOT_HOME', diff --git a/src/main/ai-vault/session-scanner-sidecar-enrichment.ts b/src/main/ai-vault/session-scanner-sidecar-enrichment.ts index 983e200e987..40ea6b2bbe3 100644 --- a/src/main/ai-vault/session-scanner-sidecar-enrichment.ts +++ b/src/main/ai-vault/session-scanner-sidecar-enrichment.ts @@ -2,6 +2,8 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command' import { generatedSessionTitle } from './session-scanner-accumulator' import { readCursorChatMeta, wasCursorChatMetaRefused } from './session-scanner-cursor-chat-meta' +import { devinSessionsIndexForSidecar } from './session-scanner-devin-db' +import type { SessionSidecarObservation } from './session-sidecar-stat' import type { SessionFileCandidate } from './session-scanner-types' /** @@ -23,7 +25,7 @@ export type SidecarEnrichment = { /** True when the sibling only adds metadata, so a change to it needs no re-parse. */ export function sidecarEnrichesWithoutReparse(candidate: SessionFileCandidate): boolean { - return candidate.agent === 'cursor' + return candidate.agent === 'cursor' || candidate.agent === 'devin' } export async function enrichSessionFromSidecar( @@ -31,14 +33,23 @@ export async function enrichSessionFromSidecar( foldSession: AiVaultSession | null, platform: NodeJS.Platform ): Promise { + if (candidate.agent === 'devin') { + return enrichDevinSessionFromDb(candidate.file.sidecar, foldSession, platform) + } if (candidate.agent !== 'cursor' || !foldSession) { return { session: foldSession, refused: false } } const meta = await readCursorChatMeta(candidate.file.path) if (!meta) { - return { session: foldSession, refused: wasCursorChatMetaRefused(candidate.file.path) } + return { + session: foldSession, + refused: wasCursorChatMetaRefused(candidate.file.path) + } + } + return { + session: mergeCursorChatMeta(foldSession, meta, platform), + refused: false } - return { session: mergeCursorChatMeta(foldSession, meta, platform), refused: false } } /** Fills only what the transcript never recorded; its own records always win. */ @@ -77,3 +88,47 @@ export function mergeCursorChatMeta( }) } } + +/** + * Merge the Devin sessions.db row onto the transcript's session. Devin's + * sibling is one shared index for the whole transcripts dir rather than a + * per-session file, so this also decides membership: a row the user hid in + * Devin's UI drops the session from the listing entirely. + */ +function enrichDevinSessionFromDb( + sidecar: SessionSidecarObservation | undefined, + foldSession: AiVaultSession | null, + platform: NodeJS.Platform +): SidecarEnrichment { + if (!foldSession) { + return { session: foldSession, refused: false } + } + const { index, unreadable } = devinSessionsIndexForSidecar(sidecar) + if (!index) { + // An observed-but-unreadable db must not settle as "no enrichment": the + // refusal keeps the sidecar unknown so the next scan retries the merge. + return { session: foldSession, refused: unreadable } + } + const row = index.get(foldSession.sessionId) + if (!row) { + return { session: foldSession, refused: false } + } + if (row.hidden) { + return { session: null, refused: false } + } + const merged = mergeCursorChatMeta( + foldSession, + { + title: row.title, + cwd: row.workingDirectory, + createdAt: row.createdAt, + updatedAt: row.lastActivityAt + }, + platform + ) + return { + session: + foldSession.model === null && row.model !== null ? { ...merged, model: row.model } : merged, + refused: false + } +} diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts index 59d5131b84c..d217c2bf489 100644 --- a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, it, vi } from 'vitest' -import type * as CodexDedup from './codex-session-root-dedup' +import type * as SessionDedup from './session-root-dedup' import type { AiVaultSession } from '../../shared/ai-vault-types' const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({ @@ -36,20 +36,20 @@ vi.mock('./remote-session-parse-cache', () => ({ parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) => candidate.session })) -vi.mock('./codex-session-root-dedup', async (original) => { - const actual = await original() +vi.mock('./session-root-dedup', async (original) => { + const actual = await original() return { ...actual, - dedupeCodexSessionsBySessionId: (sessions: AiVaultSession[]) => { + dedupeScannedSessions: (sessions: AiVaultSession[]) => { fixture.visits += sessions.length - return actual.dedupeCodexSessionsBySessionId(sessions) + return actual.dedupeScannedSessions(sessions) } } }) import { scanAiVaultSessions } from './session-scanner' import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' function candidates() { return fixture.sessions.map((session) => ({ @@ -120,7 +120,7 @@ for (const host of ['local', 'remote'] as const) { filePath: '/custom/rollout-0.jsonl' } fixture.sessions.push(session(0)) - const expected = dedupeCodexSessionsBySessionId(fixture.sessions) + const expected = dedupeScannedSessions(fixture.sessions) fixture.visits = 0 const started = performance.now() const result = await scan(true) @@ -149,7 +149,7 @@ for (const host of ['local', 'remote'] as const) { } it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => { - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() const same = session(0) const rows: AiVaultSession[] = [] const variants: AiVaultSession[] = [ @@ -169,12 +169,12 @@ it('incremental canonical selection preserves winner occurrence order, ties and const row = variants[seed % variants.length]! rows.push(row) collection.add(row) - expect([...collection.values()]).toEqual(dedupeCodexSessionsBySessionId(rows)) + expect([...collection.values()]).toEqual(dedupeScannedSessions(rows)) } }) it('retains only canonical rows during duplicate-heavy load-all scans', () => { - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() for (let index = 0; index < 10000; index++) { const row = session(index % 100) collection.add({ @@ -194,7 +194,7 @@ it('retains only canonical rows during duplicate-heavy load-all scans', () => { it('admits rows sharing one session id across rollout names without rescanning', () => { const count = 4000 let pathReads = 0 - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() for (let index = 0; index < count; index++) { const row = { ...session(index), sessionId: 'shared' } collection.add({ @@ -230,9 +230,9 @@ it('bounds per-session bookkeeping for a large mostly-unique load-all corpus', ( } : session(index) ) - const expected = dedupeCodexSessionsBySessionId(corpus) + const expected = dedupeScannedSessions(corpus) const before = heapUsed() - const collection = new CodexSessionCollection() + const collection = new ScannedSessionCollection() for (const row of corpus) { collection.add(row) } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 94c69258d59..0f1da5ac383 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,7 +6,7 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' import { createAntigravityWorkspaceResolver, readLocalAntigravityHistory, @@ -36,6 +36,7 @@ import type { import { clampPositiveInteger, errorMessage } from './session-scanner-values' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' +import { withDevinSessionsDbScan } from './session-scanner-devin-db' const SESSION_PARSE_CONCURRENCY = 8 const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 @@ -58,83 +59,85 @@ export async function scanAiVaultSessions( // The Cursor chat-meta scope spans discovery AND parse: its sibling meta.json // is looked up in both phases, and one scan must read the chats tree once. return withSpan('aiVault.scan', (span) => - withCursorChatMetaScan(async () => { - const limit = options.unlimited - ? Number.POSITIVE_INFINITY - : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) - const limitPerAgent = options.unlimited - ? Number.POSITIVE_INFINITY - : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) - const platform = options.platform ?? process.platform - const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID - const issues: AiVaultScanIssue[] = [] - const parseStats = createSessionParseStats() - const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( - readLocalAntigravityHistory - ) - // Why: persisted entries must be seeded before any candidate is parsed, or - // the cold scan gains nothing from the cache file (#9210). - throwIfAiVaultScanCancelled(options.signal) - await ensureSessionParseCacheLoaded() - const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - throwIfAiVaultScanCancelled(options.signal) + withDevinSessionsDbScan(() => + withCursorChatMetaScan(async () => { + const limit = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) + const limitPerAgent = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) + const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + const issues: AiVaultScanIssue[] = [] + const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( + readLocalAntigravityHistory + ) + // Why: persisted entries must be seeded before any candidate is parsed, or + // the cold scan gains nothing from the cache file (#9210). + throwIfAiVaultScanCancelled(options.signal) + await ensureSessionParseCacheLoaded() + const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + throwIfAiVaultScanCancelled(options.signal) - const candidates = await sessionCandidatesFromDiscoveries(discoveries, options) + const candidates = await sessionCandidatesFromDiscoveries(discoveries, options) - const parsedSessions = await parseSessionCandidates({ - candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), - limit, - platform, - executionHostId, - issues, - parseStats, - signal: options.signal, - antigravityWorkspaceResolver - }) - - const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - const scopeSessions = await scanInScopeSessions({ - discoveries, - scopePaths: options.scopePaths ?? [], - limit, - alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), - platform, - executionHostId, - issues, - parseStats, - signal: options.signal - }) - // Scope discovery can return without parsing anything, so an abort landing - // here would otherwise persist and return a cancelled scan as complete. - throwIfAiVaultScanCancelled(options.signal) - for (const refusal of cursorChatMetaRefusals()) { - // One issue per refused chats root, not one per Cursor transcript. - recordSessionScanIssue(issues, { - agent: 'cursor', - path: refusal.chatsRoot, - message: refusal.message + const parsedSessions = await parseSessionCandidates({ + candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), + limit, + platform, + executionHostId, + issues, + parseStats, + signal: options.signal, + antigravityWorkspaceResolver }) - } - span.setAttribute('candidates', candidates.length) - span.setAttribute('reused', parseStats.reused) - span.setAttribute('incremental', parseStats.incremental) - span.setAttribute('fullParses', parseStats.fullParses) - span.setAttribute('earlyStopped', parseStats.earlyStopped) - span.setAttribute('bytesRead', parseStats.bytesRead) - span.setAttribute('issues', issues.length) + const cappedSessions = dedupeScannedSessions(parsedSessions) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) - scheduleSessionParseCachePersist(parseStats) + const scopeSessions = await scanInScopeSessions({ + discoveries, + scopePaths: options.scopePaths ?? [], + limit, + alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), + platform, + executionHostId, + issues, + parseStats, + signal: options.signal + }) + // Scope discovery can return without parsing anything, so an abort landing + // here would otherwise persist and return a cancelled scan as complete. + throwIfAiVaultScanCancelled(options.signal) + for (const refusal of cursorChatMetaRefusals()) { + // One issue per refused chats root, not one per Cursor transcript. + recordSessionScanIssue(issues, { + agent: 'cursor', + path: refusal.chatsRoot, + message: refusal.message + }) + } - return { - sessions: mergeSessions(cappedSessions, scopeSessions), - issues: issues.map((issue) => ({ executionHostId, ...issue })), - scannedAt: new Date().toISOString() - } - }) + span.setAttribute('candidates', candidates.length) + span.setAttribute('reused', parseStats.reused) + span.setAttribute('incremental', parseStats.incremental) + span.setAttribute('fullParses', parseStats.fullParses) + span.setAttribute('earlyStopped', parseStats.earlyStopped) + span.setAttribute('bytesRead', parseStats.bytesRead) + span.setAttribute('issues', issues.length) + + scheduleSessionParseCachePersist(parseStats) + + return { + sessions: mergeSessions(cappedSessions, scopeSessions), + issues: issues.map((issue) => ({ executionHostId, ...issue })), + scannedAt: new Date().toISOString() + } + }) + ) ) } @@ -212,7 +215,7 @@ async function parseSessionCandidates(args: { signal?: AbortSignal antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { - const sessions = new CodexSessionCollection() + const sessions = new ScannedSessionCollection() let index = 0 while (index < args.candidates.length) { diff --git a/src/main/pi/omp-transcript-metadata.test.ts b/src/main/pi/omp-transcript-metadata.test.ts index 4c396555849..1869d78e899 100644 --- a/src/main/pi/omp-transcript-metadata.test.ts +++ b/src/main/pi/omp-transcript-metadata.test.ts @@ -26,7 +26,7 @@ describe.each([ ['dedicated OMP', { kind: 'omp' as const }], ['Pi-routed OMP', { kind: 'pi' as const, title: 'omp' }] ])('%s transcript metadata', (_name, args) => { - it('resolves custom files without scanning and retains id-based resume across switches', async () => { + it('resolves custom files without scanning and retains path-based resume across switches', async () => { const harness = createAgentStatusExtensionHarness(args) let id = '' let file = '' @@ -44,7 +44,7 @@ describe.each([ const payload = JSON.parse(String(harness.fetchMock.mock.lastCall?.[1]?.body)).payload const session = extractAgentProviderSession('omp', payload) expect(session).toEqual({ key: 'session_id', id, transcriptPath: file }) - expect(getAgentResumeArgv('omp', session!)).toEqual(['omp', '--resume', id]) + expect(getAgentResumeArgv('omp', session!)).toEqual(['omp', '--resume', file]) expect(getAgentResumeArgv('omp', session!, 'explicit.jsonl')).toEqual([ 'omp', '--resume', diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts index ec36952367e..8697368567f 100644 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -90,7 +90,7 @@ it('prepares the execution host OMP config and status extension for a guarded la source.mockReturnValue(false) expect( - augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) + await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) ).toEqual({}) } finally { runtime.stop() diff --git a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx index f511814cb2b..99522060fe5 100644 --- a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx @@ -29,7 +29,9 @@ * React commits: retained panes 1 1 * sleeping-agent records read 19,711 0 * agent-status rows read 0 0 - * workspace tab buckets read 1,394 1,394 + * workspace tab buckets read 1,394 2,081 + * + * The current count also includes the later per-workspace sleep-state reader. * * So notification work is O(mounted workspaces) and this fix does not change * that — one shared store means every subscriber is visited. What changes is @@ -392,9 +394,9 @@ describe('one pane title update: fanout at live-capture scale', () => { // the 5,500 subscribers, not only the three instrumented modules. expect(reads.sleepingRecords).toBe(0) expect(reads.agentStatusRows).toBe(0) - // One bucket lookup per workspace consumer is a keyed read; a full-inventory - // walk would be that many times 870. - expect(reads.workspaceTabBuckets).toBeLessThan(WORKSPACE_COUNT * 2) + // Activity status, card inputs, and sleep state each read their own bucket; + // a global inventory scan per consumer would multiply this by 870. + expect(reads.workspaceTabBuckets).toBeLessThan(WORKSPACE_COUNT * 3) // Only the workspace that owns the changed pane commits — the other 869 // sidebar rows hold their identities and bail out. diff --git a/tests/e2e/omp-child-session-recovery.unit.test.ts b/tests/e2e/omp-child-session-recovery.unit.test.ts index 87228545f5e..cda78bc6b39 100644 --- a/tests/e2e/omp-child-session-recovery.unit.test.ts +++ b/tests/e2e/omp-child-session-recovery.unit.test.ts @@ -9,6 +9,7 @@ import { createTestStore, makeTab } from '../../src/renderer/src/store/slices/st const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' const ROOT_SESSION = '22222222-2222-4222-8222-222222222222' +const ROOT_TRANSCRIPT_PATH = '/sessions/root.jsonl' const CHILD_SESSION = '33333333-3333-4333-8333-333333333333' async function rootWithActiveChild(): Promise> { @@ -59,7 +60,7 @@ async function rootWithActiveChild(): Promise await Promise.resolve() } } - const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => '/sessions/root.jsonl' } + const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => ROOT_TRANSCRIPT_PATH } await emit('session_start', {}, root) await emit('before_agent_start', { prompt: 'ROOT distinctive user request' }, root) await emit( @@ -109,7 +110,7 @@ describe('OMP child lifecycle recovery boundaries', () => { expect(getAgentResumeArgv(record.agent, record.providerSession)).toEqual([ 'omp', '--resume', - ROOT_SESSION + ROOT_TRANSCRIPT_PATH ]) const startup = buildAgentResumeStartupPlan({ agent: record.agent, @@ -118,7 +119,7 @@ describe('OMP child lifecycle recovery boundaries', () => { platform: 'linux', ...record.launchConfig }) - expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_SESSION}'`) + expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_TRANSCRIPT_PATH}'`) expect(serialized).not.toContain(CHILD_SESSION) }) })