mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Session History: the Workspace tab stops re-reading every transcript to find its sessions (#30469)
* perf(session-history): remember each transcripts folder so Workspace scans stop re-reading files * refactor(session-history): keep the scope cwd table in its own module * test(session-history): type the hoisted read log without an assertion * test(session-history): type the collected session folders * chore(wsl-move): list the parse cache scope_cwds table * Keep scoped session selection current with cache authority * Consolidate scope cache imports in regression test * Run scope cache SQLite regression tests under Node --------- Co-authored-by: OrcaWin <alpha-eng@stably.ai> Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
co-authored by
OrcaWin
Orca Worker
parent
2db7d5aac4
commit
da58ee0875
@@ -19,6 +19,8 @@ export const NODE_RUNTIME_INCLUDE = [
|
||||
'src/main/ai-vault/session-parse-cache-large-scan.test.ts',
|
||||
'src/main/ai-vault/session-parse-cache-metadata-checkpoint.test.ts',
|
||||
'src/main/ai-vault/session-parse-cache-persistence.test.ts',
|
||||
'src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts',
|
||||
'src/main/ai-vault/session-scanner-scope-walk.test.ts',
|
||||
// Electron main bridges streams with Node's implementation.
|
||||
'src/main/host/electron-http-client.test.ts',
|
||||
'src/main/daemon/daemon-init-provider-installation.test.ts',
|
||||
|
||||
@@ -9,8 +9,17 @@ import {
|
||||
removeVersionedCacheDatabase
|
||||
} from '../sqlite/versioned-cache-database'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store'
|
||||
import type { SessionSidecarObservation } from './session-sidecar-stat'
|
||||
import type {
|
||||
PersistedSessionParseCacheEntry,
|
||||
SessionScopeCwdLookup,
|
||||
SessionScopeCwdStamp
|
||||
} from './session-parse-cache-store'
|
||||
import {
|
||||
ensureScopeCwdsTable,
|
||||
readScopeCwdRows,
|
||||
writeScopeCwdRows
|
||||
} from './session-scope-cwds-table'
|
||||
import { parsePersistedSidecar } from './session-sidecar-stat'
|
||||
|
||||
// Bump when the row layout or cached session semantics change; a mismatched
|
||||
// database is dropped whole.
|
||||
@@ -68,6 +77,21 @@ export class SessionParseCacheDatabase {
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
/** Each path's parse-row cwd and stored scope cwd, without decoding whole sessions. */
|
||||
readScopeCwds(paths: readonly string[]) {
|
||||
const parsed = this.run((db) => readScopeCwdRows(db, paths, 'parsed'), undefined)
|
||||
const read = this.run((db) => readScopeCwdRows(db, paths, 'read'), undefined)
|
||||
const found = parsed ?? new Map<string, SessionScopeCwdLookup>()
|
||||
for (const [path, row] of read ?? []) {
|
||||
found.set(path, { ...found.get(path), ...row })
|
||||
}
|
||||
return { found, parsed: parsed !== undefined, read: read !== undefined }
|
||||
}
|
||||
|
||||
writeScopeCwds(rows: Iterable<[string, SessionScopeCwdStamp]>, seenAt: number): void {
|
||||
this.run((db) => writeScopeCwdRows(db, rows, seenAt), undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* One transaction; a caller chunks large writes so the event loop gets turns
|
||||
* between them. `keepExisting` leaves rows already present untouched.
|
||||
@@ -105,7 +129,10 @@ export class SessionParseCacheDatabase {
|
||||
}
|
||||
|
||||
delete(path: string): void {
|
||||
this.run((db) => db.prepare('DELETE FROM rows WHERE path = ?').run(path), undefined)
|
||||
this.run((db) => {
|
||||
db.prepare('DELETE FROM rows WHERE path = ?').run(path)
|
||||
db.prepare('DELETE FROM scope_cwds WHERE path = ?').run(path)
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
/** Oldest first, so repeated calls work through every stale row outside `excludeGlob`. */
|
||||
@@ -114,7 +141,8 @@ export class SessionParseCacheDatabase {
|
||||
(db) =>
|
||||
db
|
||||
.prepare(
|
||||
'SELECT path FROM rows WHERE seen_at < ? AND path NOT GLOB ? ORDER BY seen_at LIMIT ?'
|
||||
`SELECT path FROM (SELECT path, seen_at FROM rows UNION ALL SELECT path, seen_at FROM scope_cwds)
|
||||
WHERE seen_at < ? AND path NOT GLOB ? GROUP BY path ORDER BY MIN(seen_at) LIMIT ?`
|
||||
)
|
||||
.all(cutoff, excludeGlob, limit)
|
||||
.flatMap((row) => (typeof row.path === 'string' ? [row.path] : [])),
|
||||
@@ -126,18 +154,23 @@ export class SessionParseCacheDatabase {
|
||||
deleteSeenBefore(cutoff: number, glob: string): boolean {
|
||||
return this.run(
|
||||
(db) =>
|
||||
Number(
|
||||
db.prepare('DELETE FROM rows WHERE seen_at < ? AND path GLOB ?').run(cutoff, glob).changes
|
||||
) > 0,
|
||||
['rows', 'scope_cwds']
|
||||
.map((table) =>
|
||||
Number(
|
||||
db.prepare(`DELETE FROM ${table} WHERE seen_at < ? AND path GLOB ?`).run(cutoff, glob)
|
||||
.changes
|
||||
)
|
||||
)
|
||||
.some((changes) => changes > 0),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
markSeen(path: string, seenAt: number): void {
|
||||
this.run(
|
||||
(db) => db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path),
|
||||
undefined
|
||||
)
|
||||
this.run((db) => {
|
||||
db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path)
|
||||
db.prepare('UPDATE scope_cwds SET seen_at = ? WHERE path = ?').run(seenAt, path)
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
/** Hands freed pages back to the file system after deletes. */
|
||||
@@ -189,7 +222,8 @@ function openDatabase(path: string): SyncDatabase {
|
||||
version: SESSION_PARSE_CACHE_SCHEMA_VERSION,
|
||||
sql: SCHEMA_SQL,
|
||||
directoryMode: PRIVATE_DIRECTORY_MODE,
|
||||
pageSize: PAGE_SIZE
|
||||
pageSize: PAGE_SIZE,
|
||||
afterSchema: ensureScopeCwdsTable
|
||||
})
|
||||
hardenSqliteDatabaseFiles(path)
|
||||
return db
|
||||
@@ -233,19 +267,3 @@ export function parsePersistedSessionParseCacheRow(
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has
|
||||
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
|
||||
// of the rows that have a sibling and nothing at all for the rest.
|
||||
function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
|
||||
if (value === 'none' || value === 'unknown') {
|
||||
return value
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const { path, mtimeMs, sizeBytes } = value
|
||||
return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number'
|
||||
? { path, mtimeMs, sizeBytes }
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import { splitOpenCodeSqliteCandidate } from '../../shared/opencode-sqlite-sessi
|
||||
import { SessionParseCacheDatabase } from './session-parse-cache-database'
|
||||
import { importLegacySessionParseCacheJson } from './session-parse-cache-legacy-json'
|
||||
import {
|
||||
scopeCwdStampOf,
|
||||
setSessionParseCacheColdTier,
|
||||
type PersistedSessionParseCacheEntry
|
||||
type PersistedSessionParseCacheEntry,
|
||||
type SessionScopeCwdRead,
|
||||
type SessionScopeCwdStamp
|
||||
} from './session-parse-cache-store'
|
||||
|
||||
const WRITE_DEBOUNCE_MS = 1_500
|
||||
@@ -41,6 +44,7 @@ let owner = false
|
||||
// undefined: not opened yet; null: unavailable for this process's life.
|
||||
let database: SessionParseCacheDatabase | null | undefined
|
||||
const pendingWrites = new Map<string, PersistedSessionParseCacheEntry>()
|
||||
const pendingScopeCwds = new Map<string, SessionScopeCwdStamp>()
|
||||
let writeTimer: NodeJS.Timeout | null = null
|
||||
let pendingSince: number | null = null
|
||||
|
||||
@@ -55,7 +59,13 @@ export function configureSessionParseCachePersistence(
|
||||
export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void {
|
||||
configureSessionParseCachePersistence(next)
|
||||
owner = true
|
||||
setSessionParseCacheColdTier({ read: readRow, stored: queueWrite, deleted: deleteRow })
|
||||
setSessionParseCacheColdTier({
|
||||
read: readRow,
|
||||
stored: queueWrite,
|
||||
deleted: deleteRow,
|
||||
readScopeCwds,
|
||||
storedScopeCwd: queueScopeCwdWrite
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionParseCachePersistenceOptions(): SessionParseCachePersistenceOptions | null {
|
||||
@@ -65,6 +75,7 @@ export function getSessionParseCachePersistenceOptions(): SessionParseCachePersi
|
||||
export function resetSessionParseCachePersistenceForTests(): void {
|
||||
clearWriteTimer()
|
||||
pendingWrites.clear()
|
||||
pendingScopeCwds.clear()
|
||||
database?.close()
|
||||
database = undefined
|
||||
options = null
|
||||
@@ -89,6 +100,16 @@ export async function flushSessionParseCachePersist(): Promise<void> {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
while (pendingScopeCwds.size > 0) {
|
||||
const chunk = [...pendingScopeCwds].slice(0, WRITE_CHUNK_ROWS)
|
||||
for (const [path] of chunk) {
|
||||
pendingScopeCwds.delete(path)
|
||||
}
|
||||
openedDatabase()?.writeScopeCwds(chunk, Date.now())
|
||||
if (pendingScopeCwds.size > 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opened on first need, so Electron main (which never looks rows up) never opens it.
|
||||
@@ -151,9 +172,40 @@ function readRow(path: string): PersistedSessionParseCacheEntry | undefined {
|
||||
return pendingWrites.get(path) ?? openedDatabase()?.read(path)
|
||||
}
|
||||
|
||||
function readScopeCwds(paths: readonly string[]): SessionScopeCwdRead {
|
||||
const result: SessionScopeCwdRead = openedDatabase()?.readScopeCwds(paths) ?? {
|
||||
found: new Map(),
|
||||
parsed: false,
|
||||
read: false
|
||||
}
|
||||
const found = result.found
|
||||
for (const path of paths) {
|
||||
const row = pendingWrites.get(path)
|
||||
const read = pendingScopeCwds.get(path)
|
||||
if (row !== undefined || read !== undefined) {
|
||||
found.set(path, {
|
||||
...found.get(path),
|
||||
...(row === undefined ? {} : { parsed: scopeCwdStampOf(row) }),
|
||||
...(read === undefined ? {} : { read })
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function queueScopeCwdWrite(path: string, stamp: SessionScopeCwdStamp): void {
|
||||
pendingScopeCwds.delete(path)
|
||||
pendingScopeCwds.set(path, stamp)
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void {
|
||||
pendingWrites.delete(path)
|
||||
pendingWrites.set(path, entry)
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
const now = Date.now()
|
||||
pendingSince ??= now
|
||||
if (writeTimer) {
|
||||
@@ -169,6 +221,7 @@ function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void
|
||||
// Immediate, so a queued write or a later lookup can't bring the row back.
|
||||
function deleteRow(path: string): void {
|
||||
pendingWrites.delete(path)
|
||||
pendingScopeCwds.delete(path)
|
||||
openedDatabase()?.delete(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,29 @@ export type PersistedSessionParseCacheEntry = Omit<
|
||||
'resume' | 'foldSession' | 'skippedRecords'
|
||||
>
|
||||
|
||||
// A transcript version's cwd for scope membership: null means the version is
|
||||
// known to have none, so no scope holds it.
|
||||
export type SessionScopeCwdStamp = Pick<
|
||||
PersistedSessionParseCacheEntry,
|
||||
'mtimeMs' | 'sizeBytes' | 'platform' | 'sidecar'
|
||||
> & { cwd: string | null }
|
||||
|
||||
// `parsed` (from a parse row) outranks `read` (learned without a parse).
|
||||
export type SessionScopeCwdLookup = { parsed?: SessionScopeCwdStamp; read?: SessionScopeCwdStamp }
|
||||
export type SessionScopeCwdRead = {
|
||||
found: Map<string, SessionScopeCwdLookup>
|
||||
parsed: boolean
|
||||
read: boolean
|
||||
}
|
||||
|
||||
// The database behind this map (session-parse-cache-persistence.ts). A row the
|
||||
// map's bounds drop is read back from it rather than re-parsed.
|
||||
export type SessionParseCacheColdTier = {
|
||||
read(path: string): PersistedSessionParseCacheEntry | undefined
|
||||
stored(path: string, entry: PersistedSessionParseCacheEntry): void
|
||||
deleted(path: string): void
|
||||
readScopeCwds?(paths: readonly string[]): SessionScopeCwdRead
|
||||
storedScopeCwd?(path: string, stamp: SessionScopeCwdStamp): void
|
||||
}
|
||||
|
||||
const cache = new Map<string, SessionParseCacheEntry>()
|
||||
@@ -70,10 +87,19 @@ const listedPaths = new Set<string>()
|
||||
// count-bounded sets.
|
||||
const unlistedPaths = new Set<string>()
|
||||
const resumePaths = new Set<string>()
|
||||
// Scope cwds learned without a parse, for the paths the latest scope walk found.
|
||||
const readScopeCwds = new Map<string, SessionScopeCwdStamp>()
|
||||
const scopeKnowledge = new Map<string, SessionScopeCwdStamp | 'absent' | 'changed' | 'unresolved'>()
|
||||
let coldTier: SessionParseCacheColdTier | null = null
|
||||
|
||||
export function setSessionParseCacheColdTier(next: SessionParseCacheColdTier | null): void {
|
||||
coldTier = next
|
||||
clearSessionScopeCwdKnowledge()
|
||||
readScopeCwds.clear()
|
||||
}
|
||||
|
||||
export function clearSessionScopeCwdKnowledge(): void {
|
||||
scopeKnowledge.clear()
|
||||
}
|
||||
|
||||
export function resetSessionParseCacheForTests(): void {
|
||||
@@ -81,6 +107,8 @@ export function resetSessionParseCacheForTests(): void {
|
||||
listedPaths.clear()
|
||||
unlistedPaths.clear()
|
||||
resumePaths.clear()
|
||||
readScopeCwds.clear()
|
||||
clearSessionScopeCwdKnowledge()
|
||||
coldTier = null
|
||||
}
|
||||
|
||||
@@ -90,6 +118,8 @@ export function invalidateSessionParseCacheEntry(path: string): void {
|
||||
unlistedPaths.delete(path)
|
||||
resumePaths.delete(path)
|
||||
cache.delete(path)
|
||||
readScopeCwds.delete(path)
|
||||
scopeKnowledge.delete(path)
|
||||
coldTier?.deleted(path)
|
||||
}
|
||||
|
||||
@@ -100,7 +130,7 @@ export function noteSessionParseCacheListed(path: string): void {
|
||||
|
||||
export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined {
|
||||
const entry = cache.get(path)
|
||||
if (entry !== undefined || coldTier === null) {
|
||||
if (entry !== undefined || coldTier === null || scopeKnowledge.get(path) === 'unresolved') {
|
||||
return entry
|
||||
}
|
||||
const persisted = coldTier.read(path)
|
||||
@@ -117,10 +147,86 @@ export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry
|
||||
export function peekSessionParseCacheEntry(
|
||||
path: string
|
||||
): PersistedSessionParseCacheEntry | undefined {
|
||||
return cache.get(path) ?? coldTier?.read(path)
|
||||
return (
|
||||
cache.get(path) ??
|
||||
(scopeKnowledge.get(path) === 'unresolved' ? undefined : coldTier?.read(path))
|
||||
)
|
||||
}
|
||||
|
||||
/** Scope cwds for many paths at once: memory first, then one batched database read. */
|
||||
export function lookupSessionScopeCwds(paths: readonly string[]): void {
|
||||
const cold = paths.filter((path) => !cache.has(path))
|
||||
const stored =
|
||||
coldTier === null || cold.length === 0
|
||||
? { found: new Map<string, SessionScopeCwdLookup>(), parsed: true, read: true }
|
||||
: coldTier.readScopeCwds?.(cold)
|
||||
for (const path of paths) {
|
||||
const row = stored?.found.get(path)
|
||||
const parsed = row?.parsed ?? (stored?.parsed ? 'absent' : 'unresolved')
|
||||
scopeKnowledge.set(path, cache.has(path) ? 'changed' : parsed)
|
||||
const read = row?.read
|
||||
if (read && !readScopeCwds.has(path)) {
|
||||
readScopeCwds.set(path, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionScopeCwd(
|
||||
path: string,
|
||||
matches: (stamp: SessionScopeCwdStamp) => boolean
|
||||
): { cwd: string | null } | 'read' | 'parse' {
|
||||
const hot = cache.get(path)
|
||||
let parsed = hot ? scopeCwdStampOf(hot) : scopeKnowledge.get(path)
|
||||
if (parsed === 'changed') {
|
||||
const stored = coldTier?.readScopeCwds?.([path]).found.get(path)
|
||||
parsed = stored?.parsed ?? 'unresolved'
|
||||
scopeKnowledge.set(path, parsed)
|
||||
if (stored?.read && !readScopeCwds.has(path)) {
|
||||
readScopeCwds.set(path, stored.read)
|
||||
}
|
||||
}
|
||||
if (parsed === undefined || parsed === 'unresolved') {
|
||||
scopeKnowledge.set(path, 'unresolved')
|
||||
return 'parse'
|
||||
}
|
||||
for (const stamp of [typeof parsed === 'object' ? parsed : undefined, readScopeCwds.get(path)]) {
|
||||
if (stamp && matches(stamp)) {
|
||||
return { cwd: stamp.cwd }
|
||||
}
|
||||
}
|
||||
return 'read'
|
||||
}
|
||||
|
||||
export function storeSessionScopeCwd(path: string, stamp: SessionScopeCwdStamp): void {
|
||||
readScopeCwds.set(path, stamp)
|
||||
coldTier?.storedScopeCwd?.(path, stamp)
|
||||
}
|
||||
|
||||
/** Keeps in memory only the read cwds of transcripts the latest scope walk found. */
|
||||
export function pruneSessionScopeCwds(discoveredPaths: ReadonlySet<string>): void {
|
||||
for (const map of [readScopeCwds, scopeKnowledge]) {
|
||||
for (const path of map.keys()) {
|
||||
if (!discoveredPaths.has(path)) {
|
||||
map.delete(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scopeCwdStampOf(entry: PersistedSessionParseCacheEntry): SessionScopeCwdStamp {
|
||||
return {
|
||||
mtimeMs: entry.mtimeMs,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
platform: entry.platform,
|
||||
cwd: entry.session?.cwd ?? null,
|
||||
...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar })
|
||||
}
|
||||
}
|
||||
|
||||
export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void {
|
||||
if (scopeKnowledge.has(path)) {
|
||||
scopeKnowledge.set(path, 'changed')
|
||||
}
|
||||
const previous = cache.get(path)
|
||||
const changed =
|
||||
previous === undefined ||
|
||||
|
||||
@@ -68,6 +68,16 @@ function isMissingSessionPathError(error: unknown): boolean {
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
/** The session's cwd from its manifest alone; null when the manifest lists no session. */
|
||||
export function clineManifestCwd(metadataContent: string): string | null {
|
||||
const metadata = parseJsonRecord(metadataContent)
|
||||
return metadata ? clineMetadataCwd(metadata) : null
|
||||
}
|
||||
|
||||
function clineMetadataCwd(metadata: Record<string, unknown>): string | null {
|
||||
return extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
|
||||
}
|
||||
|
||||
export function parseClineSessionContent(
|
||||
file: FileWithMtime,
|
||||
metadataContent: string,
|
||||
@@ -88,7 +98,7 @@ export function parseClineSessionContent(
|
||||
sessionId,
|
||||
messages: messageSink
|
||||
})
|
||||
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
|
||||
accumulator.cwd = clineMetadataCwd(metadata)
|
||||
accumulator.model = extractString(metadata.model)
|
||||
updateTimeline(accumulator, metadata.started_at)
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ import {
|
||||
} from './session-scanner-parse-cache'
|
||||
import {
|
||||
getSessionParseCacheEntry,
|
||||
lookupSessionScopeCwds,
|
||||
resolveSessionScopeCwd,
|
||||
scopeCwdStampOf,
|
||||
peekSessionParseCacheEntry,
|
||||
type PersistedSessionParseCacheEntry,
|
||||
setSessionParseCacheColdTier
|
||||
@@ -615,6 +618,16 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript
|
||||
const read = vi.fn((key: string) => rows.get(key))
|
||||
const tier = {
|
||||
read,
|
||||
readScopeCwds: (paths: readonly string[]) => ({
|
||||
found: new Map(
|
||||
paths.flatMap((key) => {
|
||||
const row = rows.get(key)
|
||||
return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const]
|
||||
})
|
||||
),
|
||||
parsed: true,
|
||||
read: true
|
||||
}),
|
||||
stored: (key: string, row: PersistedSessionParseCacheEntry) => rows.set(key, row),
|
||||
deleted: (key: string) => rows.delete(key)
|
||||
}
|
||||
@@ -645,3 +658,30 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript
|
||||
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
|
||||
expect(await stat(path)).toMatchObject({ mtimeMs: file.mtimeMs, size: file.sizeBytes })
|
||||
})
|
||||
|
||||
it('lists a Cursor session once meta.json names the scope after a scan stored it as cwd-less', async () => {
|
||||
const cursorHome = await createCursorHome()
|
||||
const path = await writeTranscript(cursorHome, 'slug', 'chat-1', [
|
||||
JSON.stringify({ role: 'user', message: { content: 'Find the bug' } })
|
||||
])
|
||||
await utimes(path, 1, 1)
|
||||
for (const chatId of ['busy-1', 'busy-2']) {
|
||||
await writeTranscript(cursorHome, 'slug', chatId, [
|
||||
JSON.stringify({ role: 'user', message: { content: chatId } })
|
||||
])
|
||||
}
|
||||
const scan = () =>
|
||||
scanAiVaultSessions({
|
||||
...isolatedScanRoots(join(cursorHome, '..')),
|
||||
cursorProjectsDir: join(cursorHome, 'projects'),
|
||||
platform: 'darwin',
|
||||
limit: 1,
|
||||
scopePaths: ['/repo']
|
||||
})
|
||||
expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false)
|
||||
lookupSessionScopeCwds([path])
|
||||
expect(resolveSessionScopeCwd(path, () => true)).toEqual({ cwd: null })
|
||||
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-1', { cwd: '/repo' })
|
||||
const found = (await scan()).sessions.filter((row) => row.filePath === path)
|
||||
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
|
||||
})
|
||||
|
||||
@@ -68,6 +68,15 @@ export function wasCursorChatMetaRefused(transcriptPath: string): boolean {
|
||||
return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false
|
||||
}
|
||||
|
||||
/** Whether this scan could not look for the transcript's meta.json, so its absence proves nothing. */
|
||||
export function cursorChatMetaUnavailable(transcriptPath: string): boolean {
|
||||
const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath)
|
||||
return (
|
||||
wasCursorChatMetaRefused(transcriptPath) ||
|
||||
(chatsRoot !== null && scanScopedIndex.getStore()?.refusals.has(chatsRoot) === true)
|
||||
)
|
||||
}
|
||||
|
||||
/** Chats roots the current scan was refused, for the caller to report as scan issues. */
|
||||
export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] {
|
||||
const scan = scanScopedIndex.getStore()
|
||||
|
||||
@@ -42,6 +42,20 @@ type ParserSessionOptions = {
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
/** The session's cwd; a scope walk reads it from metadata.json without the context file. */
|
||||
export function rovoSessionCwd(metadata: Record<string, unknown>): string | null {
|
||||
return firstString(metadata, [
|
||||
'workspace_path',
|
||||
'workspacePath',
|
||||
'workspace',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'project_path',
|
||||
'projectPath'
|
||||
])
|
||||
}
|
||||
|
||||
export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -60,16 +74,7 @@ export async function parseRovoSessionFile(
|
||||
messages
|
||||
})
|
||||
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
|
||||
accumulator.cwd = firstString(metadata, [
|
||||
'workspace_path',
|
||||
'workspacePath',
|
||||
'workspace',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'project_path',
|
||||
'projectPath'
|
||||
])
|
||||
accumulator.cwd = rovoSessionCwd(metadata)
|
||||
updateTimeline(
|
||||
accumulator,
|
||||
extractString(metadata.created_at) ?? extractString(metadata.createdAt)
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function parseGrokSessionFile(
|
||||
const info = asRecord(record.info)
|
||||
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
|
||||
const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages })
|
||||
accumulator.cwd = extractString(info?.cwd)
|
||||
accumulator.cwd = grokSessionCwd(record)
|
||||
accumulator.title =
|
||||
normalizeTitleText(extractString(record.generated_title) ?? '') ??
|
||||
normalizeTitleText(extractString(record.session_summary) ?? '')
|
||||
@@ -60,6 +60,11 @@ export async function parseGrokSessionFile(
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
/** The session's cwd; a scope walk reads it from summary.json without the history. */
|
||||
export function grokSessionCwd(summary: Record<string, unknown>): string | null {
|
||||
return extractString(asRecord(summary.info)?.cwd)
|
||||
}
|
||||
|
||||
async function consumeGrokChatHistory(
|
||||
accumulator: SessionAccumulator,
|
||||
sessionDir: string
|
||||
|
||||
@@ -99,13 +99,18 @@ function consumeKiroTranscriptLine(accumulator: SessionAccumulator, line: string
|
||||
}
|
||||
|
||||
/** null for a subagent's child session: it is reached from its parent, not listed. */
|
||||
export function listedKiroMetadata(metadataContent: string): Record<string, unknown> | null {
|
||||
const metadata = parseJsonObject(metadataContent)
|
||||
return metadata && !extractString(metadata.parent_session_id) ? metadata : null
|
||||
}
|
||||
|
||||
function createKiroAccumulator(
|
||||
file: FileWithMtime,
|
||||
metadataContent: string,
|
||||
messages?: TranscriptMessageSink
|
||||
): SessionAccumulator | null {
|
||||
const metadata = parseJsonObject(metadataContent)
|
||||
if (!metadata || extractString(metadata.parent_session_id)) {
|
||||
const metadata = listedKiroMetadata(metadataContent)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
const accumulator = createAccumulator({
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
/** The session's cwd; a scope walk reads it from the session file without its messages. */
|
||||
export function openCodeSessionCwd(record: Record<string, unknown>): string | null {
|
||||
return extractString(record.directory)
|
||||
}
|
||||
|
||||
export async function parseOpenCodeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -36,7 +41,7 @@ export async function parseOpenCodeSessionFile(
|
||||
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
|
||||
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages })
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.directory)
|
||||
accumulator.cwd = openCodeSessionCwd(record)
|
||||
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
|
||||
updateTimeline(accumulator, timeObjectValue(record.time, 'updated'))
|
||||
await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
|
||||
import { grokEncodedCwdDirName } from '../../shared/grok-session-paths'
|
||||
import {
|
||||
flushSessionParseCachePersist,
|
||||
initSessionParseCachePersistence,
|
||||
resetSessionParseCachePersistenceForTests
|
||||
} from './session-parse-cache-persistence'
|
||||
import { resetSessionParseCacheForTests } from './session-parse-cache-store'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { SessionParseCacheDatabase } from './session-parse-cache-database'
|
||||
import { parseAgentSessionFileCached } from './session-scanner-parse-cache'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
const reads = vi.hoisted((): string[] => [])
|
||||
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WslTranscriptFsAccess>()
|
||||
return {
|
||||
...actual,
|
||||
wslGatedReadFile: (...args: Parameters<typeof actual.wslGatedReadFile>) => {
|
||||
reads.push(String(args[0]))
|
||||
return actual.wslGatedReadFile(...args)
|
||||
},
|
||||
readTranscriptSlice: (...args: Parameters<typeof actual.readTranscriptSlice>) => {
|
||||
reads.push(String(args[0]))
|
||||
return actual.readTranscriptSlice(...args)
|
||||
},
|
||||
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
|
||||
reads.push(String(args[0]))
|
||||
return actual.openTranscriptReadStream(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
reads.length = 0
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps cold authority with read failure=%s', async (failReads) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-scope-invalid-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const persistence = { filePath: join(root, 'parse-cache.json'), appVersion: 'test' }
|
||||
initSessionParseCachePersistence(persistence)
|
||||
const paths: string[] = []
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const path = await writeRollout(
|
||||
roots.codexSessionsDir,
|
||||
`old-${index}`,
|
||||
index === 0 ? '/elsewhere' : '/repo',
|
||||
index + 1
|
||||
)
|
||||
if (index === 0) {
|
||||
const context = jsonLines([{ type: 'turn_context', payload: { cwd: '/repo' } }])
|
||||
await appendFile(path, `\n${context}\n`)
|
||||
}
|
||||
const { mtimeMs, size: sizeBytes, mtime } = await stat(path)
|
||||
const parsed = await parseAgentSessionFileCached(
|
||||
{
|
||||
agent: 'codex',
|
||||
file: { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() },
|
||||
codexHome: null
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
expect(parsed?.cwd).toBe('/repo')
|
||||
paths.push(path)
|
||||
}
|
||||
await flushSessionParseCachePersist()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite'))
|
||||
for (const [index, session] of ['{', '1', '[]', 'true', '"scalar"', 'null', 'null'].entries()) {
|
||||
db.prepare('UPDATE rows SET session = ?, sidecar = ? WHERE path = ?').run(
|
||||
session,
|
||||
index === 5 ? '{' : null,
|
||||
paths[index + 1]
|
||||
)
|
||||
}
|
||||
db.prepare(
|
||||
'INSERT INTO scope_cwds SELECT path, mtime_ms, size_bytes, platform, ?, ?, seen_at FROM rows WHERE path = ?'
|
||||
).run('{', '/elsewhere', paths[0])
|
||||
db.close()
|
||||
for (let index = 0; index < 9; index++) {
|
||||
await writeRollout(roots.codexSessionsDir, `busy-${index}`, '/other', 20)
|
||||
}
|
||||
initSessionParseCachePersistence(persistence)
|
||||
const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds')
|
||||
const ordinary = vi.spyOn(SessionParseCacheDatabase.prototype, 'read')
|
||||
const prepare = SyncDatabase.prototype.prepare
|
||||
const queries = vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function (
|
||||
this: SyncDatabase,
|
||||
sql
|
||||
) {
|
||||
if (failReads && sql.startsWith('SELECT') && sql.includes('FROM rows WHERE path')) {
|
||||
throw new Error('unavailable parsed channel')
|
||||
}
|
||||
return prepare.call(this, sql)
|
||||
})
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'linux',
|
||||
limit: 8,
|
||||
scopePaths: ['/repo']
|
||||
})
|
||||
expect(
|
||||
result.sessions
|
||||
.filter((row) => row.cwd === '/repo')
|
||||
.map((row) => row.filePath)
|
||||
.sort()
|
||||
).toEqual(paths.slice(0, failReads ? 8 : 7).sort())
|
||||
expect(
|
||||
metadata.mock.calls.filter(([batch]) => batch.some((path) => paths.includes(path)))
|
||||
).toHaveLength(1)
|
||||
expect(queries.mock.calls.filter(([sql]) => sql.includes('WHERE path IN'))).toHaveLength(2)
|
||||
if (failReads) {
|
||||
expect(ordinary.mock.calls.filter(([path]) => paths.includes(path))).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
async function writeGrokSession(sessionsDir: string, id: string, cwd: string, day: number) {
|
||||
const dir = join(sessionsDir, grokEncodedCwdDirName(cwd) ?? 'unknown', id)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const timestamp = new Date(Date.UTC(2026, 9, day, 10))
|
||||
const summary = join(dir, 'summary.json')
|
||||
await writeFile(
|
||||
summary,
|
||||
JSON.stringify({
|
||||
info: { id, cwd },
|
||||
created_at: timestamp.toISOString(),
|
||||
updated_at: timestamp.toISOString()
|
||||
})
|
||||
)
|
||||
await writeFile(join(dir, 'chat_history.jsonl'), jsonLines([{ type: 'user', content: id }]))
|
||||
await utimes(summary, timestamp, timestamp)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function writeRollout(sessionsDir: string, id: string, cwd: string, day: number) {
|
||||
const timestamp = new Date(Date.UTC(2026, 9, day, 10))
|
||||
const dir = join(sessionsDir, '2026', '10', String(day).padStart(2, '0'))
|
||||
const path = join(dir, `rollout-2026-10-${String(day).padStart(2, '0')}T10-00-00-${id}.jsonl`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(
|
||||
path,
|
||||
jsonLines([
|
||||
{ timestamp, type: 'session_meta', payload: { id, cwd } },
|
||||
{
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: id }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
await utimes(path, timestamp, timestamp)
|
||||
return path
|
||||
}
|
||||
|
||||
it('settles out-of-scope transcripts once and reads none of them after a restart', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-scope-cwd-facts-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const persistence = { filePath: join(root, 'user-data', 'parse-cache.json'), appVersion: 'test' }
|
||||
initSessionParseCachePersistence(persistence)
|
||||
const others: string[] = []
|
||||
for (const day of [15, 14, 13]) {
|
||||
others.push(await writeGrokSession(roots.grokSessionsDir, `other-${day}`, '/other', day))
|
||||
}
|
||||
const quiet = await writeGrokSession(roots.grokSessionsDir, 'repo-1', '/repo', 1)
|
||||
const elsewhere = await writeRollout(roots.codexSessionsDir, 'other-2', '/other', 2)
|
||||
const scan = () =>
|
||||
scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] })
|
||||
|
||||
const listed = (await scan()).sessions.map((session) => session.cwd)
|
||||
expect(listed.filter((cwd) => cwd === '/repo')).toHaveLength(1)
|
||||
// The walked Grok session was ruled out from summary.json, never its history.
|
||||
expect(reads).not.toContain(join(others[2], 'chat_history.jsonl'))
|
||||
|
||||
await flushSessionParseCachePersist()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
initSessionParseCachePersistence(persistence)
|
||||
reads.length = 0
|
||||
|
||||
const relisted = (await scan()).sessions.filter((session) => session.cwd === '/repo')
|
||||
expect(relisted.map((session) => session.filePath)).toEqual([join(quiet, 'summary.json')])
|
||||
expect(reads.filter((path) => path.startsWith(root))).toEqual([])
|
||||
expect(reads).not.toContain(elsewhere)
|
||||
})
|
||||
@@ -6,15 +6,25 @@ import { truncateAiVaultListResult } from '../../shared/ai-vault-session-depth'
|
||||
import { parseSessionCandidates } from './session-scanner-candidate-parse'
|
||||
import {
|
||||
MAX_UNLISTED_ENTRIES,
|
||||
scopeCwdStampOf,
|
||||
peekSessionParseCacheEntry,
|
||||
setSessionParseCacheColdTier,
|
||||
type PersistedSessionParseCacheEntry,
|
||||
storeSessionParseCacheEntry,
|
||||
storeSessionScopeCwd,
|
||||
resetSessionParseCacheForTests
|
||||
} from './session-parse-cache-store'
|
||||
import { createSessionParseStats, parseAgentSessionFileCached } from './session-scanner-parse-cache'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { resetTranscriptHeadCwdCacheForTests } from './session-scope-cwd'
|
||||
import * as eventLoop from '../../shared/event-loop-yield'
|
||||
import { AI_VAULT_SCOPE_CWD_SOURCES } from './session-scope-cwd'
|
||||
import { SessionParseCacheDatabase } from './session-parse-cache-database'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import {
|
||||
initSessionParseCachePersistence,
|
||||
flushSessionParseCachePersist,
|
||||
resetSessionParseCachePersistenceForTests
|
||||
} from './session-parse-cache-persistence'
|
||||
import {
|
||||
isolatedScanRoots,
|
||||
jsonLines,
|
||||
@@ -26,12 +36,85 @@ import {
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
resetTranscriptHeadCwdCacheForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
})
|
||||
|
||||
it.each(['prefetch', 'read'] as const)('keeps an evicted parse from %s', async (boundary) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-scope-publication-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const path = join(roots.codexSessionsDir, 'rollout-published.jsonl')
|
||||
await writeJsonlFile(path, [
|
||||
{ type: 'session_meta', payload: { id: 'published', cwd: '/elsewhere' } },
|
||||
{ type: 'turn_context', payload: { cwd: '/repo' } },
|
||||
{ type: 'event_msg', payload: { type: 'user_message', message: 'Find me' } }
|
||||
])
|
||||
await utimes(path, 1, 1)
|
||||
await writeRollout(roots.codexSessionsDir, 'newer', '/other', '15')
|
||||
const { mtimeMs, size: sizeBytes, mtime } = await stat(path)
|
||||
const file = { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() }
|
||||
initSessionParseCachePersistence({ filePath: join(root, 'cache.json'), appVersion: 'test' })
|
||||
const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds')
|
||||
const publish = async () => {
|
||||
const parsed = await parseAgentSessionFileCached(
|
||||
{ agent: 'codex', file, codexHome: null },
|
||||
'linux'
|
||||
)
|
||||
expect(parsed?.cwd).toBe('/repo')
|
||||
for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) {
|
||||
storeSessionParseCacheEntry(`index-${index}`, {
|
||||
mtimeMs: mtimeMs + 1,
|
||||
sizeBytes: null,
|
||||
platform: 'linux',
|
||||
session: null,
|
||||
resume: null
|
||||
})
|
||||
}
|
||||
if (boundary === 'read') {
|
||||
await flushSessionParseCachePersist()
|
||||
}
|
||||
const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite'))
|
||||
expect(db.prepare('SELECT path FROM rows WHERE path = ?').get(path) !== undefined).toBe(
|
||||
boundary === 'read'
|
||||
)
|
||||
db.close()
|
||||
metadata.mockClear()
|
||||
}
|
||||
if (boundary === 'prefetch') {
|
||||
storeSessionScopeCwd(path, { mtimeMs, sizeBytes, platform: 'linux', cwd: '/elsewhere' })
|
||||
vi.spyOn(eventLoop, 'yieldToEventLoop').mockImplementationOnce(publish)
|
||||
} else {
|
||||
const source = AI_VAULT_SCOPE_CWD_SOURCES.codex
|
||||
if (source.kind !== 'read') {
|
||||
throw new Error('Codex must have a cheap reader')
|
||||
}
|
||||
const original = source.readCwd
|
||||
vi.spyOn(source, 'readCwd').mockImplementation(async (candidate, signal) => {
|
||||
const read = await original(candidate, signal)
|
||||
if (candidate.file.path === path) {
|
||||
await publish()
|
||||
}
|
||||
return read
|
||||
})
|
||||
}
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'linux',
|
||||
limit: 1,
|
||||
scopePaths: ['/repo']
|
||||
})
|
||||
const found = result.sessions.filter((row) => row.filePath === path)
|
||||
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
|
||||
expect(
|
||||
metadata.mock.calls.filter(([paths]) => paths.length === 1 && paths[0] === path)
|
||||
).toHaveLength(1)
|
||||
expect(await stat(path)).toMatchObject({ mtimeMs, size: sizeBytes })
|
||||
})
|
||||
|
||||
async function writeRollout(sessionsDir: string, id: string, cwd: string, day: string) {
|
||||
const dir = join(sessionsDir, '2026', '10', day)
|
||||
await mkdir(dir, { recursive: true })
|
||||
@@ -214,6 +297,16 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn
|
||||
const read = vi.fn((key: string) => rows.get(key))
|
||||
setSessionParseCacheColdTier({
|
||||
read,
|
||||
readScopeCwds: (paths) => ({
|
||||
found: new Map(
|
||||
paths.flatMap((key) => {
|
||||
const row = rows.get(key)
|
||||
return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const]
|
||||
})
|
||||
),
|
||||
parsed: true,
|
||||
read: true
|
||||
}),
|
||||
stored: (key, row) => rows.set(key, row),
|
||||
deleted: (key) => rows.delete(key)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,16 @@ import { createAiVaultScopeCwdMatcher } from '../../shared/ai-vault-scope-cwd-ma
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { noteAiVaultScanReading } from './ai-vault-scan-progress'
|
||||
import { peekSessionParseCacheEntry } from './session-parse-cache-store'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import {
|
||||
lookupSessionScopeCwds,
|
||||
clearSessionScopeCwdKnowledge,
|
||||
pruneSessionScopeCwds,
|
||||
resolveSessionScopeCwd,
|
||||
storeSessionScopeCwd,
|
||||
type SessionScopeCwdStamp
|
||||
} from './session-parse-cache-store'
|
||||
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
import { parseSessionCandidates } from './session-scanner-candidate-parse'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
@@ -15,12 +24,7 @@ import type {
|
||||
SessionFileCandidate,
|
||||
SessionFileDiscovery
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
AI_VAULT_SCOPE_CWD_SOURCES,
|
||||
pruneTranscriptHeadCwdCache,
|
||||
readTranscriptHeadCwd,
|
||||
scopeWalksAgentFiles
|
||||
} from './session-scope-cwd'
|
||||
import { AI_VAULT_SCOPE_CWD_SOURCES, scopeWalksAgentFiles } from './session-scope-cwd'
|
||||
import { sidecarUnchanged } from './session-sidecar-stat'
|
||||
import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates'
|
||||
|
||||
@@ -63,7 +67,7 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise<AiVaultS
|
||||
})
|
||||
candidates.push(...files.map((file) => ({ agent: layout.agent, file, codexHome: null })))
|
||||
}
|
||||
const walked = await walkInScopeSessions(args, scopePaths)
|
||||
const walked = await walkInScopeSessions(args, scopePaths).finally(clearSessionScopeCwdKnowledge)
|
||||
if (candidates.length === 0) {
|
||||
return walked
|
||||
}
|
||||
@@ -93,6 +97,7 @@ async function walkInScopeSessions(
|
||||
const signal = args.options.signal
|
||||
const inScope = createAiVaultScopeCwdMatcher(scopePaths)
|
||||
const candidates = walkCandidates(args, scopePaths)
|
||||
await lookupScopeCwds(candidates, signal)
|
||||
const sessions = await parseSessionCandidates({
|
||||
candidates,
|
||||
limit: args.limit,
|
||||
@@ -130,7 +135,7 @@ function walkCandidates(
|
||||
}
|
||||
all.push(...discoveryFileCandidates(discovery, files, args.options))
|
||||
}
|
||||
pruneTranscriptHeadCwdCache(seenPaths)
|
||||
pruneSessionScopeCwds(seenPaths)
|
||||
// Aliases collapse with the listed files still present, so a listed rollout's
|
||||
// hardlink in another Codex home is not parsed again.
|
||||
return prioritizeAntigravityTranscriptCandidates(
|
||||
@@ -141,7 +146,29 @@ function walkCandidates(
|
||||
)
|
||||
}
|
||||
|
||||
/** False only when a current parse or the transcript's opening records put it out of scope. */
|
||||
// Per lookup batch: one database round trip, then a turn for the event loop.
|
||||
const SCOPE_LOOKUP_BATCH = 500
|
||||
|
||||
async function lookupScopeCwds(
|
||||
candidates: readonly SessionFileCandidate[],
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
// The Antigravity fold has no cwd until the per-scan history join runs.
|
||||
const paths = candidates
|
||||
.filter((candidate) => !candidate.antigravityHistoryPath)
|
||||
.map((candidate) => candidate.file.path)
|
||||
for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_BATCH) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
lookupSessionScopeCwds(paths.slice(start, start + SCOPE_LOOKUP_BATCH))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* False only when this transcript version's cwd is known to be out of scope: a
|
||||
* parse row's cwd first, then one read without a parse. A read the agent can
|
||||
* settle is stored, so the next scan of an unchanged file reads nothing.
|
||||
*/
|
||||
async function mayBeInScope(
|
||||
candidate: SessionFileCandidate,
|
||||
inScope: ReturnType<typeof createAiVaultScopeCwdMatcher>,
|
||||
@@ -149,24 +176,41 @@ async function mayBeInScope(
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<boolean> {
|
||||
const { file } = candidate
|
||||
const entry = peekSessionParseCacheEntry(file.path)
|
||||
if (
|
||||
// The Antigravity fold has no cwd until the per-scan history join runs.
|
||||
!candidate.antigravityHistoryPath &&
|
||||
entry !== undefined &&
|
||||
entry.platform === platform &&
|
||||
entry.mtimeMs === file.mtimeMs &&
|
||||
(entry.sizeBytes === null ||
|
||||
file.sizeBytes === undefined ||
|
||||
entry.sizeBytes === file.sizeBytes) &&
|
||||
sidecarUnchanged(entry.sidecar, file.sidecar)
|
||||
) {
|
||||
return entry.session !== null && inScope(entry.session.cwd, file.path)
|
||||
}
|
||||
const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent]
|
||||
if (source.kind !== 'head') {
|
||||
if (candidate.antigravityHistoryPath) {
|
||||
return true
|
||||
}
|
||||
const cwd = await readTranscriptHeadCwd(file, source, signal)
|
||||
return cwd === null || inScope(cwd, file.path)
|
||||
const resolve = () =>
|
||||
resolveSessionScopeCwd(file.path, (stamp) => stampMatchesFile(stamp, file, platform))
|
||||
let known = resolve()
|
||||
const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent]
|
||||
if (known === 'read' && source.kind === 'read') {
|
||||
const read = await source.readCwd(candidate, signal)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (read !== null) {
|
||||
storeSessionScopeCwd(file.path, {
|
||||
mtimeMs: file.mtimeMs,
|
||||
sizeBytes: file.sizeBytes ?? null,
|
||||
platform,
|
||||
cwd: read.cwd,
|
||||
...(file.sidecar === undefined ? {} : { sidecar: file.sidecar })
|
||||
})
|
||||
}
|
||||
known = resolve()
|
||||
}
|
||||
return typeof known === 'string' || (known.cwd !== null && inScope(known.cwd, file.path))
|
||||
}
|
||||
|
||||
function stampMatchesFile(
|
||||
stamp: SessionScopeCwdStamp,
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): boolean {
|
||||
return (
|
||||
stamp.platform === platform &&
|
||||
stamp.mtimeMs === file.mtimeMs &&
|
||||
(stamp.sizeBytes === null ||
|
||||
file.sizeBytes === undefined ||
|
||||
stamp.sizeBytes === file.sizeBytes) &&
|
||||
sidecarUnchanged(stamp.sidecar, file.sidecar)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,101 @@
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { readTranscriptSlice, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import {
|
||||
CLAUDE_CWD_BUCKET_LAYOUT,
|
||||
CODEBUDDY_CWD_BUCKET_LAYOUT,
|
||||
PI_CWD_BUCKET_LAYOUT,
|
||||
type CwdBucketLayout
|
||||
} from './session-cwd-bucket-layouts'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
import { extractString, parseJsonObject } from './session-scanner-values'
|
||||
import { clineManifestCwd } from './session-scanner-cline-parser'
|
||||
import { cursorChatMetaUnavailable, readCursorChatMeta } from './session-scanner-cursor-chat-meta'
|
||||
import { rovoSessionCwd } from './session-scanner-graph-parsers'
|
||||
import { grokSessionCwd } from './session-scanner-grok-parser'
|
||||
import { listedKiroMetadata } from './session-scanner-kiro-parser'
|
||||
import { openCodeSessionCwd } from './session-scanner-opencode-parser'
|
||||
|
||||
type HeadRecord = Record<string, unknown>
|
||||
|
||||
/** A definitive cwd (null: the session has none), or null when the read could not settle it. */
|
||||
export type ScopeCwdRead = { cwd: string | null } | null
|
||||
|
||||
/**
|
||||
* How Workspace and Project views find a transcript's cwd without a full parse,
|
||||
* so an agent's older in-scope sessions are selected before the recency cap.
|
||||
* - bucket: the agent keeps one directory per cwd.
|
||||
* - head: the cwd sits in the transcript's opening records.
|
||||
* - read: a small read settles it. A settled cwd is stored per transcript
|
||||
* version, so later scans read nothing; a parse row always outranks it.
|
||||
* - parse: only the (cached) full parse knows it.
|
||||
* - none: the agent never records a cwd, so no session can belong to a scope.
|
||||
*/
|
||||
export type SessionScopeCwdSource =
|
||||
| { kind: 'bucket'; layout: CwdBucketLayout }
|
||||
| { kind: 'head'; cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number }
|
||||
| {
|
||||
kind: 'read'
|
||||
readCwd: (candidate: SessionFileCandidate, signal?: AbortSignal) => Promise<ScopeCwdRead>
|
||||
}
|
||||
| { kind: 'parse' }
|
||||
| { kind: 'none' }
|
||||
|
||||
const PARSE: SessionScopeCwdSource = { kind: 'parse' }
|
||||
|
||||
/** The cwd in the transcript's opening records; a later record can still move it, which a parse row then corrects. */
|
||||
function head(
|
||||
cwdOf: (record: HeadRecord) => unknown,
|
||||
firstReadBytes?: number
|
||||
): SessionScopeCwdSource {
|
||||
return {
|
||||
kind: 'read',
|
||||
readCwd: async ({ file }, signal) => {
|
||||
const cwd = await readTranscriptHeadCwd(file, { cwdOf, firstReadBytes }, signal)
|
||||
return cwd === null ? null : { cwd }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function document(cwdOf: (content: string) => string | null): SessionScopeCwdSource {
|
||||
return { kind: 'read', readCwd: ({ file }, signal) => readDocumentCwd(file, cwdOf, signal) }
|
||||
}
|
||||
|
||||
/** The parser's own cwd from the small file it reads first; `cwdOf` throwing is the parser's failure too. */
|
||||
async function readDocumentCwd(
|
||||
file: FileWithMtime,
|
||||
cwdOf: (content: string) => string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<ScopeCwdRead> {
|
||||
const content = await settledRead(
|
||||
() => wslGatedReadFile(file.path, 'utf-8', 'scan', signal),
|
||||
signal
|
||||
)
|
||||
if (content === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return { cwd: cwdOf(content) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function jsonRecord(content: string): Record<string, unknown> | null {
|
||||
return asRecord(JSON.parse(content) as unknown)
|
||||
}
|
||||
|
||||
// Pi-family header: `{"type":"session","cwd":...}`.
|
||||
const SESSION_HEADER: SessionScopeCwdSource = {
|
||||
kind: 'head',
|
||||
cwdOf: (record) => (record.type === 'session' ? record.cwd : undefined)
|
||||
const SESSION_HEADER = head((record) => (record.type === 'session' ? record.cwd : undefined))
|
||||
|
||||
// Cursor transcripts never record a cwd; the session's comes from its meta.json.
|
||||
const CURSOR: SessionScopeCwdSource = {
|
||||
kind: 'read',
|
||||
readCwd: async ({ file }, signal) => {
|
||||
const meta = await settledRead(() => readCursorChatMeta(file.path, signal), signal)
|
||||
if (meta) {
|
||||
return { cwd: meta.cwd }
|
||||
}
|
||||
return file.sidecar === 'none' && !cursorChatMetaUnavailable(file.path) ? { cwd: null } : null
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by every agent, so a new agent cannot list without saying how its scope is found.
|
||||
@@ -38,38 +103,58 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record<AiVaultAgent, SessionScopeCwdSou
|
||||
claude: { kind: 'bucket', layout: CLAUDE_CWD_BUCKET_LAYOUT },
|
||||
pi: { kind: 'bucket', layout: PI_CWD_BUCKET_LAYOUT },
|
||||
codebuddy: { kind: 'bucket', layout: CODEBUDDY_CWD_BUCKET_LAYOUT },
|
||||
codex: {
|
||||
kind: 'head',
|
||||
cwdOf: (record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined),
|
||||
// Its first record carries the base instructions (about 20 KB): one read, not two.
|
||||
firstReadBytes: 64 * 1024
|
||||
},
|
||||
qoder: { kind: 'head', cwdOf: (record) => record.cwd },
|
||||
// Its first record carries the base instructions (about 20 KB): one read, not two.
|
||||
codex: head(
|
||||
(record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined),
|
||||
64 * 1024
|
||||
),
|
||||
qoder: head((record) => record.cwd),
|
||||
omp: SESSION_HEADER,
|
||||
'prime-agent': SESSION_HEADER,
|
||||
openclaw: SESSION_HEADER,
|
||||
droid: {
|
||||
kind: 'head',
|
||||
cwdOf: (record) => (record.type === 'session_start' ? record.cwd : undefined)
|
||||
},
|
||||
droid: head((record) => (record.type === 'session_start' ? record.cwd : undefined)),
|
||||
// Gemini transcripts carry no cwd at all.
|
||||
gemini: { kind: 'none' },
|
||||
// Small per-session documents, a cwd recorded mid-file, a sidecar, or a database row.
|
||||
// Their parsers take the cwd from the small file discovery found, before any history.
|
||||
grok: document((content) => {
|
||||
const summary = jsonRecord(content)
|
||||
return summary ? grokSessionCwd(summary) : null
|
||||
}),
|
||||
rovo: document((content) => {
|
||||
const metadata = jsonRecord(content)
|
||||
return metadata ? rovoSessionCwd(metadata) : null
|
||||
}),
|
||||
cline: document(clineManifestCwd),
|
||||
kiro: document((content) => {
|
||||
const metadata = listedKiroMetadata(content)
|
||||
return metadata ? extractString(metadata.cwd) : null
|
||||
}),
|
||||
cursor: CURSOR,
|
||||
// Legacy session files; database rows (`<db>#<id>`) are already filtered by directory in SQL.
|
||||
opencode: {
|
||||
kind: 'read',
|
||||
readCwd: async ({ file }, signal) =>
|
||||
file.path.includes('#')
|
||||
? null
|
||||
: readDocumentCwd(
|
||||
file,
|
||||
(content) => {
|
||||
const record = jsonRecord(content)
|
||||
return record ? openCodeSessionCwd(record) : null
|
||||
},
|
||||
signal
|
||||
)
|
||||
},
|
||||
// A cwd recorded mid-file, a sidecar join, a shared index, or a database row.
|
||||
hermes: PARSE,
|
||||
cursor: PARSE,
|
||||
antigravity: PARSE,
|
||||
rovo: PARSE,
|
||||
copilot: PARSE,
|
||||
opencode: PARSE,
|
||||
opencode2: PARSE,
|
||||
zcode: PARSE,
|
||||
grok: PARSE,
|
||||
devin: PARSE,
|
||||
cline: PARSE,
|
||||
kimi: PARSE,
|
||||
muse: PARSE,
|
||||
jcode: PARSE,
|
||||
kiro: PARSE
|
||||
jcode: PARSE
|
||||
}
|
||||
|
||||
/** Whether a scan with these scope paths walks this agent's files past the recency cap. */
|
||||
@@ -78,7 +163,7 @@ export function scopeWalksAgentFiles(
|
||||
scopePaths: readonly string[] | undefined
|
||||
): boolean {
|
||||
const kind = AI_VAULT_SCOPE_CWD_SOURCES[agent].kind
|
||||
return (scopePaths?.length ?? 0) > 0 && (kind === 'head' || kind === 'parse')
|
||||
return (scopePaths?.length ?? 0) > 0 && (kind === 'read' || kind === 'parse')
|
||||
}
|
||||
|
||||
const HEAD_READ_BYTES = 8 * 1024
|
||||
@@ -94,10 +179,6 @@ export async function readTranscriptHeadCwd(
|
||||
source: { cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
const cached = headCwdCache.get(file.path)
|
||||
if (cached && cached.mtimeMs === file.mtimeMs && cached.sizeBytes === file.sizeBytes) {
|
||||
return cached.cwd
|
||||
}
|
||||
const { cwdOf } = source
|
||||
let cwd: string | null = null
|
||||
const first = source.firstReadBytes ?? HEAD_READ_BYTES
|
||||
@@ -121,13 +202,6 @@ export async function readTranscriptHeadCwd(
|
||||
break
|
||||
}
|
||||
}
|
||||
if (cwd !== null) {
|
||||
headCwdCache.set(file.path, {
|
||||
mtimeMs: file.mtimeMs,
|
||||
sizeBytes: file.sizeBytes,
|
||||
cwd: internCwd(cwd)
|
||||
})
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
@@ -145,39 +219,14 @@ function firstHeadCwd(
|
||||
return null
|
||||
}
|
||||
|
||||
// In memory only: a restart re-reads heads, and parsed files answer from the parse cache.
|
||||
const headCwdCache = new Map<
|
||||
string,
|
||||
{ mtimeMs: number; sizeBytes: number | undefined; cwd: string }
|
||||
>()
|
||||
// Thousands of transcripts share a handful of cwds.
|
||||
const cwdStrings = new Map<string, string>()
|
||||
|
||||
function internCwd(cwd: string): string {
|
||||
const existing = cwdStrings.get(cwd)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
cwdStrings.set(cwd, cwd)
|
||||
return cwd
|
||||
}
|
||||
|
||||
/** Drops cached heads for transcripts the latest scoped scan no longer discovered. */
|
||||
export function pruneTranscriptHeadCwdCache(discoveredPaths: ReadonlySet<string>): void {
|
||||
for (const path of headCwdCache.keys()) {
|
||||
if (!discoveredPaths.has(path)) {
|
||||
headCwdCache.delete(path)
|
||||
}
|
||||
}
|
||||
const liveCwds = new Set([...headCwdCache.values()].map((entry) => entry.cwd))
|
||||
for (const cwd of cwdStrings.keys()) {
|
||||
if (!liveCwds.has(cwd)) {
|
||||
cwdStrings.delete(cwd)
|
||||
/** A read's value, or null when it failed; an abort still propagates. */
|
||||
async function settledRead<T>(read: () => Promise<T>, signal?: AbortSignal): Promise<T | null> {
|
||||
try {
|
||||
return await read()
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resetTranscriptHeadCwdCacheForTests(): void {
|
||||
headCwdCache.clear()
|
||||
cwdStrings.clear()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// The scope_cwds table of the session parse cache database: a transcript
|
||||
// version's cwd learned without a parse, read beside each parse row's own cwd.
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { parsePersistedSidecar } from './session-sidecar-stat'
|
||||
import type { SessionScopeCwdLookup, SessionScopeCwdStamp } from './session-parse-cache-store'
|
||||
|
||||
// A transcript's cwd learned without a parse (its opening records, or a small
|
||||
// file its parser reads it from). Versioned apart from rows, so a reader fix
|
||||
// resets only these.
|
||||
const SCOPE_CWDS_VERSION = '1'
|
||||
const SCOPE_CWDS_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS scope_cwds(
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime_ms REAL NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
platform TEXT NOT NULL,
|
||||
sidecar TEXT,
|
||||
cwd TEXT,
|
||||
seen_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS scope_cwds_seen_at ON scope_cwds(seen_at);
|
||||
`
|
||||
// Keeps each IN list well under SQLite's bound-parameter limit.
|
||||
const SCOPE_LOOKUP_CHUNK = 500
|
||||
const VALID_SESSION =
|
||||
"CASE WHEN json_valid(session) THEN json_type(session) IN ('object','null') ELSE 0 END"
|
||||
|
||||
export function readScopeCwdRows(
|
||||
db: SyncDatabase,
|
||||
paths: readonly string[],
|
||||
channel: 'parsed' | 'read'
|
||||
): Map<string, SessionScopeCwdLookup> {
|
||||
const found = new Map<string, SessionScopeCwdLookup>()
|
||||
for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_CHUNK) {
|
||||
const chunk = paths.slice(start, start + SCOPE_LOOKUP_CHUNK)
|
||||
const marks = chunk.map(() => '?').join(',')
|
||||
for (const [table, key, cwdColumn] of [
|
||||
['rows', 'parsed', `CASE WHEN ${VALID_SESSION} THEN json_extract(session, '$.cwd') END`],
|
||||
['scope_cwds', 'read', 'cwd']
|
||||
] as const) {
|
||||
if (key !== channel) {
|
||||
continue
|
||||
}
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT path, mtime_ms, size_bytes, platform, sidecar, ${cwdColumn} AS cwd FROM ${table} WHERE path IN (${marks}) ${key === 'parsed' ? `AND (${VALID_SESSION})` : ''}`
|
||||
)
|
||||
.all(...chunk)
|
||||
for (const row of rows) {
|
||||
const stamp = parseScopeCwdStamp(row)
|
||||
if (stamp && typeof row.path === 'string') {
|
||||
found.set(row.path, { ...found.get(row.path), [key]: stamp })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export function writeScopeCwdRows(
|
||||
db: SyncDatabase,
|
||||
rows: Iterable<[string, SessionScopeCwdStamp]>,
|
||||
seenAt: number
|
||||
): void {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR REPLACE INTO scope_cwds(path, mtime_ms, size_bytes, platform, sidecar, cwd, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const [path, stamp] of rows) {
|
||||
insert.run(
|
||||
path,
|
||||
stamp.mtimeMs,
|
||||
stamp.sizeBytes,
|
||||
stamp.platform,
|
||||
stamp.sidecar === undefined ? null : JSON.stringify(stamp.sidecar),
|
||||
stamp.cwd,
|
||||
seenAt
|
||||
)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
if (db.isTransaction) {
|
||||
db.exec('ROLLBACK')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureScopeCwdsTable(db: SyncDatabase): void {
|
||||
const current = db.prepare("SELECT value FROM meta WHERE key = 'scope_cwds_version'").get()
|
||||
if (current?.value !== SCOPE_CWDS_VERSION) {
|
||||
db.exec('DROP TABLE IF EXISTS scope_cwds')
|
||||
}
|
||||
db.exec(SCOPE_CWDS_SQL)
|
||||
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('scope_cwds_version', ?)").run(
|
||||
SCOPE_CWDS_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
function parseScopeCwdStamp(row: Record<string, unknown>): SessionScopeCwdStamp | null {
|
||||
const { mtime_ms: mtimeMs, size_bytes: sizeBytes, platform, cwd } = row
|
||||
if (
|
||||
typeof mtimeMs !== 'number' ||
|
||||
(sizeBytes !== null && typeof sizeBytes !== 'number') ||
|
||||
typeof platform !== 'string' ||
|
||||
(cwd !== null && typeof cwd !== 'string')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
let sidecar: ReturnType<typeof parsePersistedSidecar>
|
||||
try {
|
||||
sidecar =
|
||||
typeof row.sidecar === 'string' ? parsePersistedSidecar(JSON.parse(row.sidecar)) : undefined
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
mtimeMs,
|
||||
sizeBytes,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only ever compared for equality with process.platform, so an unknown value is a miss, not a misuse.
|
||||
platform: platform as NodeJS.Platform,
|
||||
cwd,
|
||||
...(sidecar === undefined ? {} : { sidecar })
|
||||
}
|
||||
}
|
||||
@@ -47,3 +47,23 @@ export function sidecarUnchanged(
|
||||
entry.sizeBytes === observed.sizeBytes
|
||||
)
|
||||
}
|
||||
|
||||
// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has
|
||||
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
|
||||
// of the rows that have a sibling and nothing at all for the rest.
|
||||
export function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
|
||||
if (value === 'none' || value === 'unknown') {
|
||||
return value
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const { path, mtimeMs, sizeBytes } = value
|
||||
return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number'
|
||||
? { path, mtimeMs, sizeBytes }
|
||||
: undefined
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export const SQLITE_SCHEMA_FILE_DATABASES: Record<string, string> = {
|
||||
'src/main/ai-vault-search/session-search-query-schema.ts': 'ai-vault-search',
|
||||
'src/main/ai-vault-search/session-search-schema.ts': 'ai-vault-search',
|
||||
'src/main/ai-vault/session-parse-cache-database.ts': 'ai-vault-parse-cache',
|
||||
'src/main/ai-vault/session-scope-cwds-table.ts': 'ai-vault-parse-cache',
|
||||
'src/main/browser/browser-cookie-staged-import.ts': 'browser-cookie-import',
|
||||
'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts':
|
||||
'agent-session-journal',
|
||||
@@ -219,7 +220,7 @@ export const SQLITE_TABLE_DISPOSITIONS: Record<string, WslMoveFieldDisposition>
|
||||
),
|
||||
...tables(
|
||||
'ai-vault-parse-cache',
|
||||
['meta', 'rows'],
|
||||
['meta', 'rows', 'scope_cwds'],
|
||||
notApplicableDisposition(57, 'Session History parse cache; rebuilt from transcripts.')
|
||||
),
|
||||
...tables(
|
||||
|
||||
Reference in New Issue
Block a user