mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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 <neil@stably.ai>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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> = {}): AiVaultSession {
|
||||
@@ -23,10 +23,10 @@ function session(overrides: Partial<AiVaultSession> = {}): 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({
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,127 +223,7 @@ export async function dedupeCodexRolloutCopyAliases<T>(
|
||||
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<string, AiVaultSession>()
|
||||
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<number, AiVaultSession>()
|
||||
// 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<string, CodexSessionWinner>
|
||||
>()
|
||||
private nextIndex = 0
|
||||
|
||||
get size(): number {
|
||||
return this.sessions.size
|
||||
}
|
||||
|
||||
values(): IterableIterator<AiVaultSession> {
|
||||
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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string> }> {
|
||||
const sessions = new CodexSessionCollection()
|
||||
const sessions = new ScannedSessionCollection()
|
||||
const parsedFilePaths = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, AiVaultSession>()
|
||||
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<number, AiVaultSession>()
|
||||
// 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<string, SessionWinner>
|
||||
>()
|
||||
private nextIndex = 0
|
||||
|
||||
get size(): number {
|
||||
return this.sessions.size
|
||||
}
|
||||
|
||||
values(): IterableIterator<AiVaultSession> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<AiVaultSession> = {})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<CodexSessionCollection, 'size' | 'values'>
|
||||
type ScanSessions = Pick<ScannedSessionCollection, 'size' | 'values'>
|
||||
|
||||
function sortedCutoffIsNewer(
|
||||
times: number[],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <DEVIN_HOME>/transcripts.
|
||||
// Why: Devin ATIF transcripts live under <DEVIN_HOME>/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<AiVaultDeletableAgent, AiVaultAgentSource>
|
||||
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 `<session>/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) =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<SessionSidecarStat> {
|
||||
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<string, unknown>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, DevinSessionIndexRow>
|
||||
|
||||
// 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<Set<string>>()
|
||||
|
||||
/** A contended database must cost one timeout per scan, not one per transcript. */
|
||||
export function withDevinSessionsDbScan<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,40 +78,48 @@ function parseDevinSessionRecord(
|
||||
}
|
||||
|
||||
function extractDevinStepText(step: Record<string, unknown>): 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<string, unknown> | null,
|
||||
metrics: Record<string, unknown> | null
|
||||
metrics: Record<string, unknown> | null,
|
||||
stepMetrics: Record<string, unknown> | 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<string, unknown> | null,
|
||||
metrics: Record<string, unknown> | null,
|
||||
function firstDevinMetricValue(
|
||||
sources: readonly (Record<string, unknown> | 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<SidecarEnrichment> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof CodexDedup>()
|
||||
vi.mock('./session-root-dedup', async (original) => {
|
||||
const actual = await original<typeof SessionDedup>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<AiVaultSession[]> {
|
||||
const sessions = new CodexSessionCollection()
|
||||
const sessions = new ScannedSessionCollection()
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
+6
-4
@@ -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.
|
||||
|
||||
@@ -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<ReturnType<typeof createTestStore>> {
|
||||
@@ -59,7 +60,7 @@ async function rootWithActiveChild(): Promise<ReturnType<typeof createTestStore>
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user