fix(ai-vault): support session scanning in SSH worktrees (#11004)
* fix(ai-vault): support session scanning in SSH worktrees Add relay-native aiVault.listSessions scanning that discovers agent sessions on SSH hosts. Includes fallback to filesystem crawl for legacy relays, full cancellation support, result validation, and scan coalescing to reduce redundant work. * fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat - Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints - Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals - Report scope path truncation consistently across relay and SSH fallback paths - Gracefully degrade relay handler on unsupported platforms instead of aborting startup - Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts * fix(ai-vault): stabilize SSH session scan CI Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer fails after all tests pass. Merge main, resolve scan/relay conflicts, and align cancellation/host-issue reporting with IPC expectations. * fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption Thread the abort signal through every scan and parse path so superseded or cancelled scans stop promptly instead of parsing every remaining transcript for a caller that already left. Replace the fragile message-text relay timeout check with a typed error code so unrelated errors carrying the phrase "timed out after" no longer suppress the filesystem fallback. Fix scan coordinator preemption so a forced Refresh in one window no longer re-enters as a spurious cancellation in another. Add a host-leg cache for the all-hosts view and cap filesystem concurrency so a single slow remote home cannot stall the whole merge. Co-authored-by: Orca <help@stably.ai> * fix(ai-vault): use stable React keys for scan issue banners Drop array-index keys so react-doctor/no-array-index-as-key passes. Uniqueness comes from host, kind, agent, path, and message. * fix(ai-vault): SSH session scanning with configurable depth limits Implement depth-aware caching and proper scan boundaries to make SSH session scanning reliable in worktrees. Users can now select between faster (250 sessions) and comprehensive (unlimited) history scans. The scanner: - Deduplicates scans across relay, host leg, runtime, and renderer layers - Reuses larger scans to serve smaller depth requests - Properly bounds in-scope discovery per-limit - Fixes timeout enforcement when SSH providers ignore abort signals * Move sessionLimit ref update to useLayoutEffect Keep render pure for React Doctor by deferring ref updates to a layout effect, which still executes before render-dependent effects that consume the ref. * fix(adhoc): stamp version prefix from main, not the feature branch Adhoc builds check out arbitrary refs whose package.json often lags version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly always builds main so it already tracks the product line; adhoc now resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION) so branch builds share that prefix. * Revert "fix(adhoc): stamp version prefix from main, not the feature branch" This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a. * fix(ai-vault): fix scoped backfill and coordinator race conditions Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase. --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a7282fed40
commit
d7fe9d6bcc
|
|
@ -21,7 +21,11 @@ function fakeChild(): FakeChild {
|
|||
resume: ReturnType<typeof vi.fn>
|
||||
}
|
||||
stderr: EventEmitter
|
||||
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
|
||||
stdin: EventEmitter & {
|
||||
write: ReturnType<typeof vi.fn>
|
||||
destroyed: boolean
|
||||
writable: boolean
|
||||
}
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.stdout = Object.assign(new EventEmitter(), {
|
||||
|
|
@ -29,7 +33,18 @@ function fakeChild(): FakeChild {
|
|||
resume: vi.fn()
|
||||
})
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
|
||||
const stdin = new EventEmitter() as EventEmitter & {
|
||||
write: ReturnType<typeof vi.fn>
|
||||
destroyed: boolean
|
||||
writable: boolean
|
||||
}
|
||||
stdin.write = vi.fn((_data, onWritten?: (error?: Error | null) => void) => {
|
||||
onWritten?.(null)
|
||||
return true
|
||||
})
|
||||
stdin.destroyed = false
|
||||
stdin.writable = true
|
||||
child.stdin = stdin
|
||||
child.kill = vi.fn()
|
||||
return child as unknown as FakeChild
|
||||
}
|
||||
|
|
@ -166,7 +181,7 @@ describe('waitForWslRelaySentinel', () => {
|
|||
const drain = vi.fn()
|
||||
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
|
||||
writeMock.mockImplementation((_data, onWritten) => {
|
||||
onWritten(null)
|
||||
onWritten?.(null)
|
||||
return false
|
||||
})
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
|
|
@ -180,4 +195,16 @@ describe('waitForWslRelaySentinel', () => {
|
|||
expect(drain).toHaveBeenCalledOnce()
|
||||
expect(transport.supportsWriteSettlement).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => {
|
||||
const child = fakeChild()
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(child, RELAY_SENTINEL)
|
||||
await promise
|
||||
// Real pipes emit EPIPE on the stream after a failed write; without a
|
||||
// listener Node surfaces that as an unhandled exception (CI flake).
|
||||
expect(() => {
|
||||
child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -88,6 +88,12 @@ export function waitForWslRelaySentinel(
|
|||
child.on('error', (err) =>
|
||||
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
|
||||
)
|
||||
// Why: stdin.write() surfaces EPIPE asynchronously when the guest dies
|
||||
// mid-flight (try/catch only covers the sync throw). Without a listener
|
||||
// Node treats that as an uncaught exception and fails the whole process.
|
||||
child.stdin.on('error', () => {
|
||||
// Channel already closing — mux close handling takes over.
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
exitCode = code
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { abandonRemoteSessionScanOnCancel } from './ai-vault-scan-cancellation'
|
||||
|
||||
describe('abandonRemoteSessionScanOnCancel', () => {
|
||||
it('stops waiting on a scan that has no transport-level abort', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = abandonRemoteSessionScanOnCancel(
|
||||
new Promise<string>(() => {}),
|
||||
controller.signal
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('rejects immediately when the caller already cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'), controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('passes the scan result through without a signal', async () => {
|
||||
await expect(abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'))).resolves.toBe(
|
||||
'scanned'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { AI_VAULT_SCAN_CANCELLED_MESSAGE } from '../../shared/ai-vault-types'
|
||||
|
||||
export function throwIfAiVaultScanCancelled(signal?: AbortSignal): void {
|
||||
if (!signal?.aborted) {
|
||||
return
|
||||
}
|
||||
throw createAiVaultScanCancelledError()
|
||||
}
|
||||
|
||||
// Why: the runtime RPC transport has no abort hook, so cancellation can only
|
||||
// stop the caller waiting on it — the in-flight request settles on its own
|
||||
// timeout instead of holding an 'all'-scope merge open after every waiter left.
|
||||
export function abandonRemoteSessionScanOnCancel<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
// The scanner promise already exists; observe it so its later failure is
|
||||
// not an unhandled rejection after this caller walked away.
|
||||
void promise.catch(() => undefined)
|
||||
reject(createAiVaultScanCancelledError())
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => reject(createAiVaultScanCancelledError())
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function createAiVaultScanCancelledError(): Error {
|
||||
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
|
||||
|
||||
const EMPTY_RESULT = { sessions: [], issues: [], scannedAt: '2026-07-27T00:00:00.000Z' }
|
||||
|
||||
describe('AiVaultScanCoordinator', () => {
|
||||
it('keeps a shared scan alive when only one caller cancels', async () => {
|
||||
const coordinator = new AiVaultScanCoordinator()
|
||||
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
|
||||
let sharedSignal: AbortSignal | undefined
|
||||
const start = vi.fn(
|
||||
(signal: AbortSignal) =>
|
||||
new Promise<typeof EMPTY_RESULT>((resolve) => {
|
||||
sharedSignal = signal
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
|
||||
const second = coordinator.run({ key: 'scope', start })
|
||||
await Promise.resolve()
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(sharedSignal?.aborted).toBe(false)
|
||||
resolveScan?.(EMPTY_RESULT)
|
||||
await expect(second).resolves.toEqual(EMPTY_RESULT)
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preempts a non-forced scan and re-joins its caller onto the forced scan', async () => {
|
||||
const coordinator = new AiVaultScanCoordinator()
|
||||
const signals: AbortSignal[] = []
|
||||
let resolveForced: ((result: typeof EMPTY_RESULT) => void) | undefined
|
||||
const start = vi.fn((signal: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
return new Promise<typeof EMPTY_RESULT>((resolve) => {
|
||||
if (signals.length === 1) {
|
||||
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
|
||||
} else {
|
||||
resolveForced = resolve
|
||||
}
|
||||
})
|
||||
})
|
||||
const first = coordinator.run({ key: 'scope', start })
|
||||
await Promise.resolve()
|
||||
|
||||
const forced = coordinator.run({ key: 'scope', force: true, start })
|
||||
const joined = coordinator.run({ key: 'scope', force: true, start })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(start).toHaveBeenCalledTimes(2)
|
||||
resolveForced?.(EMPTY_RESULT)
|
||||
// The first caller never asked to cancel, so someone else's Refresh must
|
||||
// hand it the replacement's result instead of a spurious cancellation.
|
||||
await expect(Promise.all([first, forced, joined])).resolves.toEqual([
|
||||
EMPTY_RESULT,
|
||||
EMPTY_RESULT,
|
||||
EMPTY_RESULT
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps coalescing forced callers onto a forced scan that is still fresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const coordinator = new AiVaultScanCoordinator()
|
||||
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
|
||||
const start = vi.fn(
|
||||
() =>
|
||||
new Promise<typeof EMPTY_RESULT>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
const first = coordinator.run({ key: 'scope', force: true, start })
|
||||
await Promise.resolve()
|
||||
|
||||
vi.advanceTimersByTime(4_999)
|
||||
const second = coordinator.run({ key: 'scope', force: true, start })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
resolveScan?.(EMPTY_RESULT)
|
||||
// Both callers settle off the single shared scan instead of preempting it.
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a forced refresh preempt a forced scan that hung past the coalescing window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const coordinator = new AiVaultScanCoordinator()
|
||||
const signals: AbortSignal[] = []
|
||||
let resolveSecond: ((result: typeof EMPTY_RESULT) => void) | undefined
|
||||
const start = vi.fn((signal: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
return new Promise<typeof EMPTY_RESULT>((resolve) => {
|
||||
if (signals.length > 1) {
|
||||
resolveSecond = resolve
|
||||
}
|
||||
})
|
||||
})
|
||||
const stuck = coordinator.run({ key: 'scope', force: true, start })
|
||||
await Promise.resolve()
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
const retry = coordinator.run({ key: 'scope', force: true, start })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(start).toHaveBeenCalledTimes(2)
|
||||
resolveSecond?.(EMPTY_RESULT)
|
||||
// The caller stranded on the hung scan rides the replacement out rather
|
||||
// than being told its own refresh was cancelled.
|
||||
await expect(Promise.all([stuck, retry])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts a fresh scan after every waiter cancels', async () => {
|
||||
const coordinator = new AiVaultScanCoordinator()
|
||||
const signals: AbortSignal[] = []
|
||||
const start = vi.fn((signal: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (signals.length > 1) {
|
||||
return Promise.resolve(EMPTY_RESULT)
|
||||
}
|
||||
return new Promise<typeof EMPTY_RESULT>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
|
||||
})
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
const second = coordinator.run({ key: 'scope', start })
|
||||
|
||||
await expect(second).resolves.toEqual(EMPTY_RESULT)
|
||||
expect(start).toHaveBeenCalledTimes(2)
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import {
|
||||
AI_VAULT_SCAN_CANCELLED_MESSAGE,
|
||||
type AiVaultListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
|
||||
// Matches the renderer's forced-rescan throttle: forced callers that arrive
|
||||
// inside this window share one scan, later ones may preempt a scan that hung.
|
||||
const FORCED_SCAN_PREEMPT_AFTER_MS = 5_000
|
||||
|
||||
type ScanEntry = {
|
||||
controller: AbortController
|
||||
force: boolean
|
||||
startedAt: number
|
||||
promise: Promise<AiVaultListResult>
|
||||
waiterCount: number
|
||||
// The last waiter leaving must not abort a scan that already finished.
|
||||
settled: boolean
|
||||
// Set when a forced caller replaced this entry, so its waiters re-join the
|
||||
// replacement instead of surfacing someone else's refresh as their own cancel.
|
||||
preemptedBy: ScanEntry | null
|
||||
}
|
||||
|
||||
export class AiVaultScanCoordinator {
|
||||
private readonly entries = new Map<string, ScanEntry>()
|
||||
|
||||
run(args: {
|
||||
key: string
|
||||
force?: boolean
|
||||
signal?: AbortSignal
|
||||
start: (signal: AbortSignal) => Promise<AiVaultListResult>
|
||||
}): Promise<AiVaultListResult> {
|
||||
if (args.signal?.aborted) {
|
||||
return Promise.reject(scanCancellationError())
|
||||
}
|
||||
let entry = this.entries.get(args.key)
|
||||
const preempted = entry && args.force === true && canPreemptForForcedScan(entry) ? entry : null
|
||||
if (preempted) {
|
||||
this.removeEntry(args.key, preempted)
|
||||
entry = undefined
|
||||
}
|
||||
if (!entry) {
|
||||
entry = this.createEntry(args.key, args.force === true, args.start)
|
||||
this.entries.set(args.key, entry)
|
||||
}
|
||||
if (preempted) {
|
||||
// The replacement must be registered before the abort lands: waiters of
|
||||
// the old scan re-join it synchronously from their abort listener.
|
||||
preempted.preemptedBy = entry
|
||||
preempted.controller.abort()
|
||||
}
|
||||
return this.attach(args.key, entry, args.signal)
|
||||
}
|
||||
|
||||
private createEntry(
|
||||
key: string,
|
||||
force: boolean,
|
||||
start: (signal: AbortSignal) => Promise<AiVaultListResult>
|
||||
): ScanEntry {
|
||||
const controller = new AbortController()
|
||||
const entry: ScanEntry = {
|
||||
controller,
|
||||
force,
|
||||
startedAt: Date.now(),
|
||||
promise: Promise.resolve().then(() => {
|
||||
if (controller.signal.aborted) {
|
||||
throw scanCancellationError()
|
||||
}
|
||||
return start(controller.signal)
|
||||
}),
|
||||
waiterCount: 0,
|
||||
settled: false,
|
||||
preemptedBy: null
|
||||
}
|
||||
const onSettled = (): void => {
|
||||
entry.settled = true
|
||||
this.removeEntry(key, entry)
|
||||
}
|
||||
void entry.promise.then(onSettled, onSettled)
|
||||
return entry
|
||||
}
|
||||
|
||||
private attach(key: string, entry: ScanEntry, signal?: AbortSignal): Promise<AiVaultListResult> {
|
||||
entry.waiterCount++
|
||||
return new Promise((resolve, reject) => {
|
||||
let attached = true
|
||||
const detach = (): void => {
|
||||
if (!attached) {
|
||||
return
|
||||
}
|
||||
attached = false
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
entry.controller.signal.removeEventListener('abort', onAbort)
|
||||
entry.waiterCount--
|
||||
if (entry.waiterCount === 0 && !entry.settled && !entry.controller.signal.aborted) {
|
||||
this.removeEntry(key, entry)
|
||||
entry.controller.abort()
|
||||
}
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
if (!attached) {
|
||||
return
|
||||
}
|
||||
// Why: a forced refresh aborts the shared entry, but the other waiters
|
||||
// never asked to cancel — rejecting them turns one window's Refresh into
|
||||
// a cancelled multi-host merge somewhere else. Re-join the replacement.
|
||||
const replacement = signal?.aborted ? null : entry.preemptedBy
|
||||
detach()
|
||||
if (!replacement) {
|
||||
reject(scanCancellationError())
|
||||
return
|
||||
}
|
||||
void this.attach(key, replacement, signal).then(resolve, reject)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
entry.controller.signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted || entry.controller.signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
void entry.promise.then(
|
||||
(result) => {
|
||||
if (attached) {
|
||||
detach()
|
||||
resolve(result)
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (attached) {
|
||||
detach()
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private removeEntry(key: string, entry: ScanEntry): void {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: without this a forced scan that never settles (no inactivity timer on the
|
||||
// legacy SSH file-stream reader, see #11364) makes every later Refresh join the
|
||||
// dead promise, so the panel stays empty until the app restarts.
|
||||
function canPreemptForForcedScan(entry: ScanEntry): boolean {
|
||||
return !entry.force || Date.now() - entry.startedAt >= FORCED_SCAN_PREEMPT_AFTER_MS
|
||||
}
|
||||
|
||||
function scanCancellationError(): Error {
|
||||
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
|
@ -3,6 +3,13 @@ import { scanAiVaultSessions } from './session-scanner'
|
|||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
|
||||
import {
|
||||
aiVaultSessionDepthCovers,
|
||||
requestedAiVaultSessionDepth,
|
||||
truncateAiVaultListResult,
|
||||
type AiVaultSessionDepth
|
||||
} from '../../shared/ai-vault-session-depth'
|
||||
|
||||
// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime
|
||||
// RPC method share a single cache instance — opening the desktop panel and the
|
||||
|
|
@ -19,65 +26,77 @@ export type AiVaultSessionSources = {
|
|||
|
||||
type CachedAiVaultList = {
|
||||
key: string
|
||||
depth: AiVaultSessionDepth
|
||||
result: AiVaultListResult
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let cachedList: CachedAiVaultList | null = null
|
||||
let inflightList: Promise<AiVaultListResult> | null = null
|
||||
let inflightKey: string | null = null
|
||||
let scanCoordinator = new AiVaultScanCoordinator()
|
||||
let sources: AiVaultSessionSources = {}
|
||||
|
||||
export function configureAiVaultSessionSources(next: AiVaultSessionSources): void {
|
||||
sources = next
|
||||
}
|
||||
|
||||
export async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
export async function listAiVaultSessions(
|
||||
args?: AiVaultListArgs,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? []
|
||||
})
|
||||
const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scanKey = JSON.stringify({ key, depth })
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
// transcripts; explicit refreshes bypass the cache but not an active scan.
|
||||
if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) {
|
||||
return cachedList.result
|
||||
// transcripts; explicit refreshes bypass the cache and preempt stale scans.
|
||||
if (
|
||||
args?.force !== true &&
|
||||
cachedList?.key === key &&
|
||||
cachedList.expiresAt > now &&
|
||||
aiVaultSessionDepthCovers(cachedList.depth, depth)
|
||||
) {
|
||||
return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths)
|
||||
}
|
||||
if (inflightList && inflightKey === key) {
|
||||
return inflightList
|
||||
}
|
||||
|
||||
inflightKey = key
|
||||
const additionalCodexSessionsDirs =
|
||||
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
|
||||
inflightList = (async () =>
|
||||
scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs(),
|
||||
// Why: this scan is always host-local; callers addressing this host by a
|
||||
// runtime id get the result restamped at the RPC edge, never rescanned.
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
}))()
|
||||
.then((result) => {
|
||||
cachedList = {
|
||||
key,
|
||||
result,
|
||||
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
|
||||
return scanCoordinator.run({
|
||||
key: scanKey,
|
||||
force: args?.force,
|
||||
signal: options.signal,
|
||||
start: async (scanSignal) => {
|
||||
const additionalCodexSessionsDirs =
|
||||
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
|
||||
const result = await scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs(),
|
||||
// Cancelled/superseded callers must stop the parse, not just stop
|
||||
// waiting for it — the scan owns hundreds of transcript reads.
|
||||
signal: scanSignal,
|
||||
// Why: this scan is always host-local; callers addressing this host by a
|
||||
// runtime id get the result restamped at the RPC edge, never rescanned.
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
if (!scanSignal.aborted) {
|
||||
const current = cachedList
|
||||
if (
|
||||
args?.force === true ||
|
||||
current?.key !== key ||
|
||||
current.expiresAt <= Date.now() ||
|
||||
!aiVaultSessionDepthCovers(current.depth, depth)
|
||||
) {
|
||||
cachedList = {
|
||||
key,
|
||||
depth,
|
||||
result,
|
||||
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
// Only clear tracking if it still refers to this request: a concurrent
|
||||
// different-key scan may have replaced it and must stay dedupable.
|
||||
if (inflightKey === key) {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
}
|
||||
})
|
||||
return inflightList
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Exported for the subagent-transcript IPC path, which validates
|
||||
|
|
@ -95,7 +114,6 @@ export async function getAiVaultWslHomeDirs(): Promise<string[]> {
|
|||
// Why: tests reset module-level cache/source state between cases.
|
||||
export function resetAiVaultSessionListCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
inflightKey = null
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
sources = {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
const REMOTE_CONTENT_YIELD_LINE_COUNT = 200
|
||||
const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024
|
||||
|
||||
/** Splits transcript content into lines. Without a signal there is nothing to
|
||||
* observe, so it splits in one pass; with one it yields to the event loop so a
|
||||
* cancelled scan stops mid-transcript instead of parsing megabytes for a caller
|
||||
* that already left. */
|
||||
export function remoteSessionContentLines(
|
||||
content: string,
|
||||
signal?: AbortSignal
|
||||
): Iterable<string> | AsyncIterable<string> {
|
||||
return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/)
|
||||
}
|
||||
|
||||
async function* cancellableContentLines(
|
||||
content: string,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<string> {
|
||||
// Content below one yield window would otherwise never observe cancellation.
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
let lineStart = 0
|
||||
let yieldStart = 0
|
||||
let linesSinceYield = 0
|
||||
|
||||
for (let index = 0; index <= content.length; index++) {
|
||||
if (index < content.length && content.charCodeAt(index) !== 10) {
|
||||
continue
|
||||
}
|
||||
const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index
|
||||
yield content.slice(lineStart, lineEnd)
|
||||
lineStart = index + 1
|
||||
linesSinceYield++
|
||||
if (
|
||||
linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT ||
|
||||
index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT
|
||||
) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
await yieldToEventLoop()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
linesSinceYield = 0
|
||||
yieldStart = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,24 @@
|
|||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { FileStat } from '../providers/types'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
export async function statRemoteSessionFile(
|
||||
provider: IFilesystemProvider,
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
path: string,
|
||||
agent: AiVaultAgent,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[],
|
||||
options?: { missingIsExpected?: boolean }
|
||||
options?: { missingIsExpected?: boolean; signal?: AbortSignal }
|
||||
): Promise<FileWithMtime | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
const stat = await provider.stat(path)
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
const mtimeMs = remoteSessionMtimeMs(stat)
|
||||
return {
|
||||
path,
|
||||
|
|
@ -25,8 +30,14 @@ export async function statRemoteSessionFile(
|
|||
...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
|
||||
issues.push({ executionHostId, agent, path, message: errorMessage(error) })
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent,
|
||||
path,
|
||||
message: errorMessage(error)
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
|
||||
describe('mapRemoteScanBatches', () => {
|
||||
it('observes an abort that lands while the final batch yields', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches(
|
||||
['a', 'b'],
|
||||
2,
|
||||
async (item) => {
|
||||
controller.abort()
|
||||
return item
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('observes an abort for empty inputs that never enter the loop', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches([], 8, async (item) => item, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns every batch result when nothing cancels', async () => {
|
||||
expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
// Batching bounds concurrent remote round trips; the yield between batches keeps
|
||||
// the process responsive, and the batch boundary is where cancellation lands.
|
||||
export async function mapRemoteScanBatches<T, U>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<U>,
|
||||
signal?: AbortSignal
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += concurrency) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper))))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
// An abort can land while the last batch yields, and empty inputs never enter
|
||||
// the loop at all — observe it here so neither path returns as a success.
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return results
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
|
||||
// Why: discovery batches (8 sources) each stat in batches of 8, and parse
|
||||
// batches read whole transcripts — nested fan-out put ~64 filesystem round
|
||||
// trips on one SSH mux or relay event loop at once, blowing the scan budget and
|
||||
// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling.
|
||||
const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8
|
||||
|
||||
/** Wraps a provider so all of one scan's filesystem calls share a single
|
||||
* in-flight ceiling, regardless of how the callers nest their batches. */
|
||||
export function limitRemoteScanFilesystemConcurrency(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY
|
||||
): RemoteSessionFilesystemProvider {
|
||||
const gate = createConcurrencyGate(maxInFlight)
|
||||
return {
|
||||
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
|
||||
readFile: (filePath) => gate(() => provider.readFile(filePath)),
|
||||
stat: (filePath) => gate(() => provider.stat(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
function createConcurrencyGate(maxInFlight: number): <T>(run: () => Promise<T>) => Promise<T> {
|
||||
const limit = Math.max(1, Math.floor(maxInFlight))
|
||||
const waiting: (() => void)[] = []
|
||||
let inFlight = 0
|
||||
|
||||
return async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
if (inFlight < limit) {
|
||||
inFlight++
|
||||
} else {
|
||||
// The releasing call hands its slot over directly, so a queued caller can
|
||||
// never race a fresh one into an over-limit slot.
|
||||
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
}
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
const next = waiting.shift()
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
inFlight--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
const REMOTE_SCAN_ISSUE_LIMIT = 500
|
||||
|
||||
export function recordRemoteSessionScanIssue(
|
||||
issues: AiVaultScanIssue[],
|
||||
issue: AiVaultScanIssue
|
||||
): void {
|
||||
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
issues.push(issue)
|
||||
return
|
||||
}
|
||||
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
// Kinded: this row is a scan notice, not a skipped transcript — the panel
|
||||
// counts unkinded issues as skipped transcript files.
|
||||
issues.push({
|
||||
executionHostId: issue.executionHostId,
|
||||
agent: issue.agent,
|
||||
kind: 'notice',
|
||||
path: 'Agent Session History scan',
|
||||
message: 'Additional scan issues were omitted.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,50 @@
|
|||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: IFilesystemProvider
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
signal?: AbortSignal
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
|
||||
const pending = readRemoteCodexIndexTitles(
|
||||
args.provider,
|
||||
args.codexHome,
|
||||
args.hostPlatform,
|
||||
args.signal
|
||||
)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: IFilesystemProvider,
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
signal?: AbortSignal
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
for await (const line of remoteSessionContentLines(content, signal)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
|
|
@ -45,6 +56,7 @@ async function readRemoteCodexIndexTitles(
|
|||
}
|
||||
}
|
||||
} catch {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote
|
|||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
|
|
@ -26,15 +29,22 @@ export async function discoverRemoteSourceCandidates(args: {
|
|||
? partitionSubagentTranscriptPaths(walked)
|
||||
: null
|
||||
const paths = partition ? partition.sessionFilePaths : walked
|
||||
const files = await mapDiscoveryConcurrently(paths, (path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{ missingIsExpected: Boolean(args.source.fixedChildFileSegments) }
|
||||
)
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{
|
||||
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
|
||||
signal: args.context.signal
|
||||
}
|
||||
),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
|
|
@ -50,10 +60,12 @@ async function listRemoteFixedChildFiles(
|
|||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(source.rootDir)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
|
||||
return []
|
||||
}
|
||||
|
|
@ -73,10 +85,12 @@ async function walkRemoteSessionFiles(
|
|||
dirPath = source.rootDir,
|
||||
depth = 0
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(dirPath)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
|
||||
return []
|
||||
}
|
||||
|
|
@ -84,6 +98,7 @@ async function walkRemoteSessionFiles(
|
|||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
|
||||
if (
|
||||
entry.isDirectory &&
|
||||
|
|
@ -112,18 +127,12 @@ function recordRemoteDirectoryIssue(
|
|||
err: unknown
|
||||
): void {
|
||||
if (!isMissingRemoteSessionPathError(err)) {
|
||||
issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) })
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent: source.agent,
|
||||
kind: 'host',
|
||||
path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function mapDiscoveryConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ type RemoteContentParser = (
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
// Line-based parsers iterate cancellably; whole-document parsers ignore it.
|
||||
signal?: AbortSignal
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
|
|
@ -126,7 +128,8 @@ function remoteAntigravitySource(
|
|||
file,
|
||||
content,
|
||||
context.hostPlatform.os,
|
||||
parserOptions(context)
|
||||
parserOptions(context),
|
||||
context.signal
|
||||
)
|
||||
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
|
||||
}
|
||||
|
|
@ -150,7 +153,9 @@ function source(
|
|||
filePredicate,
|
||||
directoryPredicate,
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
|
||||
Promise.resolve(
|
||||
parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,13 +198,15 @@ function remoteCodexSources(
|
|||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
signal: context.signal,
|
||||
readIndexedTitle: async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform,
|
||||
titleCaches: context.titleCaches
|
||||
titleCaches: context.titleCaches,
|
||||
signal: context.signal
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
})
|
||||
|
|
@ -233,27 +240,30 @@ function piParser(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,19 @@ import type { FileWithMtime } from './session-scanner-types'
|
|||
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
|
||||
export type RemoteScannerContext = {
|
||||
provider: IFilesystemProvider
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
hostPlatform: RemoteHostPlatform
|
||||
signal?: AbortSignal
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
antigravityWorkspaceResolver: AntigravityWorkspaceResolver
|
||||
}
|
||||
|
||||
export type RemoteSessionFilesystemProvider = Pick<
|
||||
IFilesystemProvider,
|
||||
'readDir' | 'readFile' | 'stat'
|
||||
>
|
||||
|
||||
export type RemoteParserOptions = {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform: NodeJS.Platform
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ describe('scanRemoteAiVaultSessions', () => {
|
|||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
unlimited: true
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
|
|
@ -404,11 +406,13 @@ describe('scanRemoteAiVaultSessions', () => {
|
|||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agent: 'antigravity',
|
||||
kind: 'host',
|
||||
path: brainDir,
|
||||
message: expect.stringContaining('EACCES')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
kind: 'host',
|
||||
path: claudeProjectDir,
|
||||
message: expect.stringContaining('ECONNRESET')
|
||||
})
|
||||
|
|
@ -706,6 +710,93 @@ describe('scanRemoteAiVaultSessions', () => {
|
|||
'scoped-session'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
for (const [sessionId, mtimeMs, hour] of [
|
||||
['other-newest', 50, '05'],
|
||||
['other-newer', 40, '04']
|
||||
] as const) {
|
||||
provider.addFile(
|
||||
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: `2026-07-04T${hour}:00:00.000Z`
|
||||
}),
|
||||
mtimeMs
|
||||
)
|
||||
}
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-newest',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
|
||||
it('caps scoped backfill at the requested limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
for (const [sessionId, mtimeMs] of [
|
||||
['newer-scoped', 30],
|
||||
['older-scoped', 20]
|
||||
] as const) {
|
||||
provider.addFile(
|
||||
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z`
|
||||
}),
|
||||
mtimeMs
|
||||
)
|
||||
}
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'newer-scoped'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function codexTranscript(args: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
} from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
codexRolloutHardlinkIdentity,
|
||||
|
|
@ -14,44 +14,71 @@ import {
|
|||
} from './codex-session-root-dedup'
|
||||
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionFilesystemProvider
|
||||
} from './remote-session-scanner-types'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
|
||||
|
||||
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
|
||||
const REMOTE_SCAN_CONCURRENCY = 8
|
||||
const REMOTE_SCOPE_PARSE_LIMIT = 2000
|
||||
const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2
|
||||
// Remote scope membership is only known after a transcript is read, so the scope
|
||||
// backfill carries its own ceiling on remote reads instead of borrowing the
|
||||
// recency cap — under the cap, newer out-of-scope files ate the scope budget and
|
||||
// silently dropped older in-scope sessions the scope contract guarantees.
|
||||
const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000
|
||||
|
||||
export async function scanRemoteAiVaultSessions(args: {
|
||||
provider: IFilesystemProvider
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
scopePaths?: readonly string[]
|
||||
signal?: AbortSignal
|
||||
}): Promise<AiVaultListResult> {
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
const limit = aiVaultScanLimit(args)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
// One ceiling for the whole scan: discovery walks, stats and transcript reads
|
||||
// all queue behind it instead of multiplying into a nested fan-out.
|
||||
const provider = limitRemoteScanFilesystemConcurrency(args.provider)
|
||||
const context: RemoteScannerContext = {
|
||||
provider: args.provider,
|
||||
provider,
|
||||
executionHostId: args.executionHostId,
|
||||
hostPlatform: args.hostPlatform,
|
||||
signal: args.signal,
|
||||
titleCaches: new Map(),
|
||||
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => {
|
||||
try {
|
||||
const read = await args.provider.readFile(historyPath)
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
const read = await provider.readFile(historyPath)
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
return read.isBinary ? null : read.content
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
const candidates = dedupeCodexRolloutFileAliases(
|
||||
(
|
||||
await mapRemoteScanConcurrently(
|
||||
await mapRemoteScanBatches(
|
||||
remoteSessionSources(args.remoteHome, args.hostPlatform),
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues })
|
||||
REMOTE_SCAN_CONCURRENCY,
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues }),
|
||||
args.signal
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
|
|
@ -64,7 +91,12 @@ export async function scanRemoteAiVaultSessions(args: {
|
|||
}
|
||||
)
|
||||
|
||||
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
|
||||
const parsed = await parseRemoteSessionCandidates({
|
||||
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
|
||||
context,
|
||||
issues,
|
||||
limit
|
||||
})
|
||||
const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions)
|
||||
const cappedSessions = parsedSessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
|
|
@ -78,12 +110,15 @@ export async function scanRemoteAiVaultSessions(args: {
|
|||
context,
|
||||
issues,
|
||||
scopePaths,
|
||||
limit,
|
||||
alreadyParsedFilePaths: parsed.parsedFilePaths
|
||||
})
|
||||
const scopeSessions = dedupeCodexSessionsBySessionId([
|
||||
...parsedScopeSessions,
|
||||
...extraScopeSessions
|
||||
])
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
|
||||
return {
|
||||
sessions: mergeRemoteSessions(cappedSessions, scopeSessions),
|
||||
|
|
@ -107,19 +142,27 @@ async function parseRemoteSessionCandidates(args: {
|
|||
break
|
||||
}
|
||||
|
||||
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
const remaining = args.candidates.length - index
|
||||
const needed = Math.max(args.limit - sessions.length, 1)
|
||||
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
for (const candidate of batch) {
|
||||
parsedFilePaths.add(candidate.file.path)
|
||||
}
|
||||
throwIfAiVaultScanCancelled(args.context.signal)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(...results.filter(isAiVaultSession))
|
||||
const uniqueSessions = dedupeCodexSessionsBySessionId(sessions)
|
||||
sessions.splice(0, sessions.length, ...uniqueSessions)
|
||||
index += batch.length
|
||||
index += batchSize
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
|
||||
// The loop can terminate on the yield after its final batch, so re-check
|
||||
// rather than letting a cancelled scan return a partial parse as a success.
|
||||
throwIfAiVaultScanCancelled(args.context.signal)
|
||||
return { sessions, parsedFilePaths }
|
||||
}
|
||||
|
||||
|
|
@ -128,21 +171,29 @@ async function scanRemoteInScopeSessions(args: {
|
|||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
scopePaths: readonly string[]
|
||||
limit: number
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = args.candidates
|
||||
.filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
|
||||
.slice(0, REMOTE_SCOPE_PARSE_LIMIT)
|
||||
const candidates = args.candidates.filter(
|
||||
(candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)
|
||||
)
|
||||
const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT)
|
||||
const sessions: AiVaultSession[] = []
|
||||
let index = 0
|
||||
|
||||
for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
// Keep reading newest-first until the scope has its requested number of
|
||||
// sessions; out-of-scope candidates no longer end the search.
|
||||
while (index < bound && sessions.length < args.limit) {
|
||||
const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound)
|
||||
const results = await mapRemoteScanBatches(
|
||||
candidates.slice(index, batchEnd),
|
||||
REMOTE_SCAN_CONCURRENCY,
|
||||
(candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
sessions.push(
|
||||
...results.filter(
|
||||
|
|
@ -150,6 +201,17 @@ async function scanRemoteInScopeSessions(args: {
|
|||
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
|
||||
)
|
||||
)
|
||||
index = batchEnd
|
||||
}
|
||||
|
||||
if (index < candidates.length && sessions.length < args.limit) {
|
||||
recordRemoteSessionScanIssue(args.issues, {
|
||||
executionHostId: args.context.executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: 'Agent Session History scan',
|
||||
message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.`
|
||||
})
|
||||
}
|
||||
|
||||
return sessions
|
||||
|
|
@ -161,11 +223,14 @@ async function parseRemoteSessionCandidate(
|
|||
issues: AiVaultScanIssue[]
|
||||
): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(candidate.file.path)
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
const session = await candidate.source.parse(candidate.file, read.content, context)
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
// Mirror the local rule: every session carries its sibling subagent
|
||||
// transcript count (row badge; recoverable signal at zero turns). The
|
||||
// walk listing supplies it — the parser can't readdir a remote disk.
|
||||
|
|
@ -175,7 +240,8 @@ async function parseRemoteSessionCandidate(
|
|||
}
|
||||
return session
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
|
|
@ -232,15 +298,3 @@ function canStopParsingRemoteSessions(
|
|||
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
|
||||
return Boolean(session)
|
||||
}
|
||||
|
||||
async function mapRemoteScanConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ describe('runtime AI Vault session scanner', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('surfaces project scope truncation from the runtime transport bound', async () => {
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/srv/repo-${index}`)
|
||||
|
||||
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {
|
||||
scopePaths
|
||||
})
|
||||
|
||||
expect(scanResult.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'scope',
|
||||
message: expect.stringContaining('first 64 project paths')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps sessions and issues returned for a different execution host', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { z } from 'zod'
|
||||
import {
|
||||
AI_VAULT_AGENTS,
|
||||
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult,
|
||||
type AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
} from '../../shared/ai-vault-resume-preparation'
|
||||
import { parseAiVaultListResult } from './session-list-result-validation'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
|
|
@ -23,90 +23,6 @@ export type RuntimeAiVaultScanOptions = {
|
|||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const nodePlatformSchema = z.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
] satisfies NodeJS.Platform[])
|
||||
|
||||
const aiVaultSessionPreviewMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
|
||||
text: z.string(),
|
||||
timestamp: z.string().nullable()
|
||||
})
|
||||
|
||||
const executionHostIdSchema = z.string().transform((value, ctx) => {
|
||||
const normalized = normalizeExecutionHostId(value)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid execution host id'
|
||||
})
|
||||
return z.NEVER
|
||||
})
|
||||
|
||||
const aiVaultListResultSchema = z.object({
|
||||
sessions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
executionHostId: executionHostIdSchema,
|
||||
executionHostPlatform: nodePlatformSchema.nullable().optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionId: z.string(),
|
||||
title: z.string(),
|
||||
cwd: z.string().nullable(),
|
||||
branch: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
filePath: z.string(),
|
||||
codexHome: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
modifiedAt: z.string(),
|
||||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
|
||||
// Optional keeps paired hosts on older builds compatible.
|
||||
previewMessagesTruncated: z.boolean().optional(),
|
||||
firstUserPrompt: z.string().nullable().optional(),
|
||||
lastUserPrompt: z.string().nullable().optional(),
|
||||
// Default keeps remote hosts running an older build (no recoverable-signal
|
||||
// fields) parseable; they simply report no recoverable-empty sessions.
|
||||
queuedMessageCount: z.number().default(0),
|
||||
subagentTranscriptCount: z.number().default(0),
|
||||
resumeCommand: z.string(),
|
||||
// The default keeps remote hosts running an older build (no subagent
|
||||
// field) parseable; scanned top-level sessions carry null anyway.
|
||||
subagent: z
|
||||
.object({
|
||||
parentSessionId: z.string(),
|
||||
agentType: z.string().nullable(),
|
||||
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
|
||||
})
|
||||
.nullable()
|
||||
.default(null)
|
||||
})
|
||||
),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
executionHostId: executionHostIdSchema.optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
path: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
),
|
||||
scannedAt: z.string()
|
||||
})
|
||||
|
||||
// Why: zod strips unknown keys, so the repin home must be declared or the
|
||||
// parent would silently drop it and resume under the wrong account's home.
|
||||
const aiVaultPrepareSessionResumeResultSchema = z.object({
|
||||
|
|
@ -136,6 +52,7 @@ export async function scanRuntimeAiVaultSessions(
|
|||
'aiVault.listSessions',
|
||||
{
|
||||
limit: args.limit,
|
||||
unlimited: args.unlimited,
|
||||
force: args.force,
|
||||
// Why: cap here so the set of scanned paths is explicit on this side —
|
||||
// the RPC schema CLAMPS to the same bound anyway (older hosts had no
|
||||
|
|
@ -147,17 +64,36 @@ export async function scanRuntimeAiVaultSessions(
|
|||
options.timeoutMs
|
||||
)
|
||||
if (response.ok === true) {
|
||||
const parsed = aiVaultListResultSchema.safeParse(response.result)
|
||||
if (parsed.success) {
|
||||
return withRuntimeExecutionHost(parsed.data, executionHostId)
|
||||
try {
|
||||
const result = withRuntimeExecutionHost(
|
||||
parseAiVaultListResult(response.result),
|
||||
executionHostId
|
||||
)
|
||||
if (!args.scopePaths || args.scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
issues: [
|
||||
...result.issues,
|
||||
{
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: environmentId,
|
||||
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: `Invalid aiVault.listSessions response: ${
|
||||
error instanceof Error ? error.message : 'unexpected result shape'
|
||||
}`
|
||||
})
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: `Invalid aiVault.listSessions response: ${
|
||||
parsed.error.issues[0]?.message ?? 'unexpected result shape'
|
||||
}`
|
||||
})
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
|
|
@ -224,6 +160,7 @@ function runtimeScanIssueResult(args: {
|
|||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: args.environmentId,
|
||||
message: args.message
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAiVaultListResult } from './session-list-result-validation'
|
||||
|
||||
describe('parseAiVaultListResult', () => {
|
||||
it('keeps valid sessions when another wire entry is malformed', () => {
|
||||
const parsed = parseAiVaultListResult({
|
||||
sessions: [validSession(), { id: 42 }],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-27T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(parsed.sessions).toHaveLength(1)
|
||||
expect(parsed.sessions[0]?.sessionId).toBe('session-1')
|
||||
expect(parsed.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: 'aiVault.listSessions',
|
||||
message: expect.stringContaining('Skipped 1 invalid')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the optional session fields the panel renders', () => {
|
||||
const parsed = parseAiVaultListResult({
|
||||
sessions: [
|
||||
{
|
||||
...validSession(),
|
||||
previewMessagesTruncated: true,
|
||||
firstUserPrompt: 'first',
|
||||
lastUserPrompt: 'last'
|
||||
}
|
||||
],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-27T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(parsed.sessions[0]).toMatchObject({
|
||||
previewMessagesTruncated: true,
|
||||
firstUserPrompt: 'first',
|
||||
lastUserPrompt: 'last'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed result envelope', () => {
|
||||
expect(() => parseAiVaultListResult({ sessions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a nonempty sessions array when every row is invalid', () => {
|
||||
expect(() =>
|
||||
parseAiVaultListResult({
|
||||
sessions: [{ id: 42 }, null],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-27T00:00:00.000Z'
|
||||
})
|
||||
).toThrow('all supplied Agent Session History sessions were invalid')
|
||||
})
|
||||
})
|
||||
|
||||
function validSession(): Record<string, unknown> {
|
||||
return {
|
||||
id: 'local:codex:session-1:/tmp/session-1.jsonl',
|
||||
executionHostId: 'local',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
title: 'Session one',
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/tmp/session-1.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-07-27T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: 'codex resume session-1',
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { z } from 'zod'
|
||||
import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const nodePlatformSchema = z.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
] satisfies NodeJS.Platform[])
|
||||
|
||||
const executionHostIdSchema = z.string().transform((value, ctx) => {
|
||||
const normalized = normalizeExecutionHostId(value)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid execution host id' })
|
||||
return z.NEVER
|
||||
})
|
||||
|
||||
const sessionPreviewMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
|
||||
text: z.string(),
|
||||
timestamp: z.string().nullable()
|
||||
})
|
||||
|
||||
const aiVaultSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
executionHostId: executionHostIdSchema,
|
||||
executionHostPlatform: nodePlatformSchema.nullable().optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionId: z.string(),
|
||||
title: z.string(),
|
||||
cwd: z.string().nullable(),
|
||||
branch: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
filePath: z.string(),
|
||||
codexHome: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
modifiedAt: z.string(),
|
||||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(sessionPreviewMessageSchema),
|
||||
previewMessagesTruncated: z.boolean().optional(),
|
||||
firstUserPrompt: z.string().nullable().optional(),
|
||||
lastUserPrompt: z.string().nullable().optional(),
|
||||
queuedMessageCount: z.number().default(0),
|
||||
subagentTranscriptCount: z.number().default(0),
|
||||
resumeCommand: z.string(),
|
||||
subagent: z
|
||||
.object({
|
||||
parentSessionId: z.string(),
|
||||
agentType: z.string().nullable(),
|
||||
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
|
||||
})
|
||||
.nullable()
|
||||
.default(null)
|
||||
})
|
||||
|
||||
const aiVaultScanIssueSchema = z.object({
|
||||
executionHostId: executionHostIdSchema.optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
kind: z.enum(['host', 'scope', 'notice']).optional(),
|
||||
path: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
const aiVaultListResultEnvelopeSchema = z.object({
|
||||
sessions: z.array(z.unknown()),
|
||||
issues: z.array(z.unknown()),
|
||||
scannedAt: z.string()
|
||||
})
|
||||
|
||||
export function parseAiVaultListResult(value: unknown): AiVaultListResult {
|
||||
const envelope = aiVaultListResultEnvelopeSchema.safeParse(value)
|
||||
if (!envelope.success) {
|
||||
throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape')
|
||||
}
|
||||
const sessions = envelope.data.sessions.flatMap((session) => {
|
||||
const parsed = aiVaultSessionSchema.safeParse(session)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
if (envelope.data.sessions.length > 0 && sessions.length === 0) {
|
||||
throw new Error('all supplied Agent Session History sessions were invalid')
|
||||
}
|
||||
const issues = envelope.data.issues.flatMap((issue) => {
|
||||
const parsed = aiVaultScanIssueSchema.safeParse(issue)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
const invalidCount =
|
||||
envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length)
|
||||
if (invalidCount > 0) {
|
||||
issues.push({
|
||||
agent: 'codex',
|
||||
path: 'aiVault.listSessions',
|
||||
message: `Skipped ${invalidCount} invalid Agent Session History result ${invalidCount === 1 ? 'entry' : 'entries'}.`
|
||||
})
|
||||
}
|
||||
return { sessions, issues, scannedAt: envelope.data.scannedAt }
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { mergeAiVaultListResults } from './session-list-results'
|
||||
|
||||
function listResult(issues: AiVaultScanIssue[]): AiVaultListResult {
|
||||
return { sessions: [], issues, scannedAt: '2026-08-02T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function session(index: number): AiVaultSession {
|
||||
const id = `session-${index}`
|
||||
const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString()
|
||||
return {
|
||||
id,
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: id,
|
||||
title: id,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${id}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
modifiedAt: timestamp,
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: id,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPE_TRUNCATION: AiVaultScanIssue = {
|
||||
executionHostId: 'ssh:dev-box',
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: '/home/ada',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
}
|
||||
|
||||
describe('mergeAiVaultListResults', () => {
|
||||
it('does not cap an Unlimited all-host merge', () => {
|
||||
const sessions = Array.from({ length: 1001 }, (_, index) => session(index))
|
||||
const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true)
|
||||
|
||||
expect(merged.sessions).toHaveLength(1001)
|
||||
})
|
||||
|
||||
it('keeps a per-host scope truncation notice when merging all-host results', () => {
|
||||
const merged = mergeAiVaultListResults(
|
||||
[listResult([]), listResult([SCOPE_TRUNCATION])],
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(merged.issues).toEqual([SCOPE_TRUNCATION])
|
||||
})
|
||||
|
||||
it('keeps one scope notice per host rather than collapsing them', () => {
|
||||
const otherHost: AiVaultScanIssue = { ...SCOPE_TRUNCATION, executionHostId: 'ssh:build-box' }
|
||||
|
||||
const merged = mergeAiVaultListResults(
|
||||
[listResult([SCOPE_TRUNCATION]), listResult([otherHost])],
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(merged.issues.map((issue) => issue.executionHostId)).toEqual([
|
||||
'ssh:dev-box',
|
||||
'ssh:build-box'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a scope notice alongside a failing host so one bad host is not the whole story', () => {
|
||||
const hostDown: AiVaultScanIssue = {
|
||||
executionHostId: 'ssh:build-box',
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: 'build-box',
|
||||
message: 'Remote connection dropped.'
|
||||
}
|
||||
|
||||
const merged = mergeAiVaultListResults(
|
||||
[listResult([SCOPE_TRUNCATION]), listResult([hostDown])],
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(merged.issues).toEqual([SCOPE_TRUNCATION, hostDown])
|
||||
// Kinded issues render as their own banner rows, never as skipped transcripts.
|
||||
expect(merged.issues.filter((issue) => !issue.kind)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
|
||||
|
||||
export function aiVaultScanIssueResult(args: {
|
||||
executionHostId?: ExecutionHostId
|
||||
|
|
@ -17,6 +18,7 @@ export function aiVaultScanIssueResult(args: {
|
|||
{
|
||||
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: args.path,
|
||||
message: args.message
|
||||
}
|
||||
|
|
@ -25,6 +27,12 @@ export function aiVaultScanIssueResult(args: {
|
|||
}
|
||||
}
|
||||
|
||||
// A superseded scan has no findings to report; the flag tells the renderer to
|
||||
// keep the list it already has rather than paint this empty body.
|
||||
export function cancelledAiVaultListResult(): AiVaultListResult {
|
||||
return { sessions: [], issues: [], scannedAt: new Date().toISOString(), cancelled: true }
|
||||
}
|
||||
|
||||
// Why: the serving-side scan is host-local and cached once for every caller
|
||||
// (desktop parent, web, mobile), so callers that address this host by a runtime
|
||||
// id get the cached result restamped on the way out instead of a per-host scan.
|
||||
|
|
@ -50,9 +58,10 @@ export function restampAiVaultListResult(
|
|||
|
||||
export function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
rawLimit: number | undefined,
|
||||
unlimited = false
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const limit = aiVaultScanLimit({ limit: rawLimit, unlimited })
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
|
@ -36,11 +37,12 @@ export async function parseAntigravitySessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseAntigravitySessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
parseJsonObject,
|
||||
subtractCodexUsage
|
||||
} from './session-scanner-values'
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
|
|
@ -61,10 +62,11 @@ export async function parseCodexSessionContent(args: {
|
|||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
readIndexedTitle?: (sessionId: string) => Promise<string | null>
|
||||
signal?: AbortSignal
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return parseCodexSessionLines({
|
||||
file: args.file,
|
||||
lines: args.content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(args.content, args.signal),
|
||||
platform: args.platform ?? process.platform,
|
||||
codexHome: args.codexHome ?? null,
|
||||
executionHostId: args.executionHostId,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
|
@ -44,11 +45,12 @@ export async function parseCopilotSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCopilotSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
|
@ -42,11 +43,12 @@ export async function parseCursorSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCursorSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
|
@ -44,11 +45,12 @@ export async function parseDroidSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseDroidSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
|
@ -40,12 +41,13 @@ export async function parseGeminiSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ResumableParseFinalizeOptions = {}
|
||||
options: ResumableParseFinalizeOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
|
@ -184,12 +185,13 @@ export async function parseMessageGraphSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionLines({
|
||||
agent,
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -135,6 +135,22 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('omits the SQL limit for an Unlimited scan', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applySchema(db)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
insertSession(db, `ses_${i}`, 1_777_634_000_000 + i * 1000)
|
||||
}
|
||||
db.close()
|
||||
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: Number.POSITIVE_INFINITY,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('uses time_created recency when time_updated is not positive', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applySchema(db)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
function buildSessionListQuery(db: SyncDatabase): string {
|
||||
function buildSessionListQuery(db: SyncDatabase, limited: boolean): string {
|
||||
const parentIdPredicate = columnExists(db, 'session', 'parent_id') ? 'AND parent_id IS NULL' : ''
|
||||
const archivedPredicate = columnExists(db, 'session', 'time_archived')
|
||||
? 'AND time_archived IS NULL'
|
||||
|
|
@ -45,7 +45,7 @@ function buildSessionListQuery(db: SyncDatabase): string {
|
|||
FROM session
|
||||
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
|
||||
ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC
|
||||
LIMIT ?`
|
||||
${limited ? 'LIMIT ?' : ''}`
|
||||
}
|
||||
|
||||
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
|
||||
|
|
@ -105,7 +105,9 @@ export async function listOpenCodeSqliteSessions(args: {
|
|||
if (!canReadOpenCodeSessions(db)) {
|
||||
continue
|
||||
}
|
||||
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
|
||||
const limited = Number.isFinite(args.limit)
|
||||
const statement = db.prepare(buildSessionListQuery(db, limited))
|
||||
const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[]
|
||||
for (const row of rows) {
|
||||
candidates.push(rowToCandidate(row, dbPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
|
@ -219,11 +220,12 @@ export async function parseClaudeSessionContent(
|
|||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseClaudeSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
lines: remoteSessionContentLines(content, signal),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,12 +34,16 @@ export type AiVaultScanOptions = {
|
|||
droidProjectsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
limitPerAgent?: number
|
||||
// Active workspace/project paths whose sessions must be included regardless of
|
||||
// the recency cap (see discoverInScopeClaudeFiles).
|
||||
scopePaths?: readonly string[]
|
||||
platform?: NodeJS.Platform
|
||||
executionHostId?: ExecutionHostId
|
||||
// Superseded/cancelled scans stop between parse batches instead of parsing
|
||||
// every remaining transcript for a caller that already left.
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
|
|
|
|||
|
|
@ -154,7 +154,9 @@ describe('scanAiVaultSessions', () => {
|
|||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin'
|
||||
platform: 'darwin',
|
||||
limit: 1,
|
||||
unlimited: true
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
|
|
|
|||
|
|
@ -39,13 +39,11 @@ import type {
|
|||
SessionParseResult
|
||||
} from './session-scanner-types'
|
||||
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'
|
||||
|
||||
const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
// Upper bound on extra in-scope transcripts discovered and parsed past the
|
||||
// recency cap; guards against a pathological scoped history directory.
|
||||
const SCOPE_PARSE_LIMIT = 2000
|
||||
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
|
||||
|
||||
/**
|
||||
* Scan all supported AI agent session stores and return a unified, sorted,
|
||||
|
|
@ -63,8 +61,12 @@ export async function scanAiVaultSessions(
|
|||
// "one core pegged" reports need to show whether transcript scanning is the
|
||||
// subsystem burning CPU, and how much of each scan the cache absorbed.
|
||||
return withSpan('aiVault.scan', async (span) => {
|
||||
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
|
||||
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
|
||||
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[] = []
|
||||
|
|
@ -72,8 +74,10 @@ export async function scanAiVaultSessions(
|
|||
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile)
|
||||
// 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 = dedupeCodexRolloutFileAliases(
|
||||
discoveries
|
||||
|
|
@ -106,12 +110,13 @@ export async function scanAiVaultSessions(
|
|||
)
|
||||
|
||||
const parsedSessions = await parseSessionCandidates({
|
||||
candidates,
|
||||
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
|
||||
limit,
|
||||
platform,
|
||||
executionHostId,
|
||||
issues,
|
||||
parseStats,
|
||||
signal: options.signal,
|
||||
antigravityWorkspaceResolver
|
||||
})
|
||||
|
||||
|
|
@ -122,12 +127,17 @@ export async function scanAiVaultSessions(
|
|||
const scopeSessions = await scanInScopeSessions({
|
||||
discoveries,
|
||||
scopePaths: options.scopePaths ?? [],
|
||||
limit,
|
||||
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
|
||||
platform,
|
||||
executionHostId,
|
||||
issues,
|
||||
parseStats
|
||||
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)
|
||||
|
||||
span.setAttribute('candidates', candidates.length)
|
||||
span.setAttribute('reused', parseStats.reused)
|
||||
|
|
@ -169,11 +179,13 @@ function mergeSessions(
|
|||
async function scanInScopeSessions(args: {
|
||||
discoveries: SessionFileDiscovery[]
|
||||
scopePaths: readonly string[]
|
||||
limit: number
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
parseStats: SessionParseStats
|
||||
signal?: AbortSignal
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
|
|
@ -184,7 +196,7 @@ async function scanInScopeSessions(args: {
|
|||
const files = await discoverInScopeClaudeFiles({
|
||||
rootDirs: claudeRootDirs,
|
||||
scopePaths: args.scopePaths,
|
||||
limit: SCOPE_PARSE_LIMIT,
|
||||
limit: args.limit,
|
||||
excludedFilePaths: args.alreadyParsedFilePaths,
|
||||
issues: args.issues
|
||||
})
|
||||
|
|
@ -201,7 +213,8 @@ async function scanInScopeSessions(args: {
|
|||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
issues: args.issues,
|
||||
parseStats: args.parseStats
|
||||
parseStats: args.parseStats,
|
||||
signal: args.signal
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -212,12 +225,14 @@ async function parseSessionCandidates(args: {
|
|||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
parseStats: SessionParseStats
|
||||
signal?: AbortSignal
|
||||
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver
|
||||
}): Promise<AiVaultSession[]> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
|
|
@ -255,6 +270,9 @@ async function parseSessionCandidates(args: {
|
|||
index += batchSize
|
||||
}
|
||||
|
||||
// An abort can land while the final batch settles; observe it here so a
|
||||
// partial parse is never cached or returned as a complete scan.
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
return sessions
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
const requestActiveSshAiVaultSessionList = vi.fn()
|
||||
const getActiveSshAiVaultHostInfo = vi.fn()
|
||||
const getSshFilesystemProvider = vi.fn()
|
||||
const scanRemoteAiVaultSessions = vi.fn()
|
||||
|
||||
vi.mock('../ipc/ssh', () => ({
|
||||
requestActiveSshAiVaultSessionList: (...args: unknown[]) =>
|
||||
requestActiveSshAiVaultSessionList(...args),
|
||||
getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args),
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.'
|
||||
}))
|
||||
|
||||
vi.mock('./remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args)
|
||||
}))
|
||||
|
||||
const { scanSshAiVaultSessions } = await import('./ssh-session-list')
|
||||
|
||||
describe('scanSshAiVaultSessions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' })
|
||||
getSshFilesystemProvider.mockReturnValue({})
|
||||
})
|
||||
|
||||
it('returns at the deadline when the legacy crawl ignores abort', async () => {
|
||||
// Relay without the method resolves null, so the leg falls through to the
|
||||
// desktop crawl — which used to run unbounded even under an all-host budget.
|
||||
vi.useFakeTimers()
|
||||
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
let fallbackSignal: AbortSignal | undefined
|
||||
scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => {
|
||||
fallbackSignal = signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 })
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)])
|
||||
|
||||
expect(result).not.toBe('still-pending')
|
||||
expect(fallbackSignal?.aborted).toBe(true)
|
||||
if (result === 'still-pending') {
|
||||
return
|
||||
}
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
kind: 'host',
|
||||
message: 'Agent Session History scan timed out after 20ms on this SSH host.'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the crawl unbounded when no budget was requested', async () => {
|
||||
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
|
||||
|
||||
await scanSshAiVaultSessions('dev-box')
|
||||
|
||||
expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: undefined })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => {
|
||||
// Why: `all` scope awaits every host leg together, so a throw here would
|
||||
// discard the local sessions alongside this host's.
|
||||
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up'))
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box')
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' })
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds the relay round trip separately from the whole leg', async () => {
|
||||
// The relay needs seconds to walk a real remote home; only the leg total
|
||||
// has to stay short enough that one host cannot hold the merge open.
|
||||
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
|
||||
|
||||
await scanSshAiVaultSessions('dev-box', undefined, {
|
||||
timeoutMs: 20_000,
|
||||
relayTimeoutMs: 15_000
|
||||
})
|
||||
|
||||
expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
|
||||
'dev-box',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ timeoutMs: 15_000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a relay scan that outran the old three-second bound', async () => {
|
||||
// Regression: the shared 3s budget emptied healthy hosts in the all-hosts
|
||||
// view. A relay answering inside the scan budget must keep its sessions.
|
||||
requestActiveSshAiVaultSessionList.mockImplementation(
|
||||
(_targetId: string, _params: unknown, options: { timeoutMs?: number }) =>
|
||||
(options.timeoutMs ?? 0) > 3_000
|
||||
? Promise.resolve({
|
||||
sessions: [remoteSession()],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-02T00:00:00.000Z'
|
||||
})
|
||||
: Promise.reject(relayTimeoutError())
|
||||
)
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box', undefined, {
|
||||
timeoutMs: 20_000,
|
||||
relayTimeoutMs: 15_000
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
|
||||
expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a host issue when the relay timed out on a real scan budget', async () => {
|
||||
// A relay that had a fair scan window will not answer faster over the far
|
||||
// slower filesystem crawl, so retrying it only stalls the merge.
|
||||
requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box', undefined, {
|
||||
timeoutMs: 20_000,
|
||||
relayTimeoutMs: 15_000
|
||||
})
|
||||
|
||||
expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' })
|
||||
])
|
||||
})
|
||||
|
||||
it('still falls back when the relay budget was too short for a fair attempt', async () => {
|
||||
requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
|
||||
scanRemoteAiVaultSessions.mockResolvedValue({
|
||||
sessions: [remoteSession()],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-02T00:00:00.000Z'
|
||||
})
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box', undefined, {
|
||||
timeoutMs: 3_000,
|
||||
relayTimeoutMs: 2_000
|
||||
})
|
||||
|
||||
expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
|
||||
})
|
||||
|
||||
it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => {
|
||||
requestActiveSshAiVaultSessionList.mockRejectedValue(
|
||||
new Error('the remote agent timed out after loading its index')
|
||||
)
|
||||
scanRemoteAiVaultSessions.mockResolvedValue({
|
||||
sessions: [remoteSession()],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-02T00:00:00.000Z'
|
||||
})
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box', undefined, {
|
||||
timeoutMs: 20_000,
|
||||
relayTimeoutMs: 15_000
|
||||
})
|
||||
|
||||
expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
|
||||
})
|
||||
|
||||
it('reports the relay error when the fallback crawl recovered nothing', async () => {
|
||||
// Otherwise a broken relay over an empty crawl reads as a healthy but empty
|
||||
// host, and the panel shows no reason for the missing sessions.
|
||||
requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded'))
|
||||
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
|
||||
|
||||
const result = await scanSshAiVaultSessions('dev-box')
|
||||
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' })
|
||||
])
|
||||
})
|
||||
|
||||
it('still propagates a caller cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const result = scanSshAiVaultSessions('dev-box', undefined, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1))
|
||||
controller.abort()
|
||||
|
||||
await expect(result).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
function emptyResult(): AiVaultListResult {
|
||||
return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not
|
||||
* on the message text, so a look-alike message must not take that branch. */
|
||||
function relayTimeoutError(): Error {
|
||||
return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), {
|
||||
code: SSH_MUX_REQUEST_TIMEOUT_CODE
|
||||
})
|
||||
}
|
||||
|
||||
// Relay rows are re-validated before they are trusted, so this has to satisfy
|
||||
// the full session schema rather than a partial stub.
|
||||
function remoteSession(): AiVaultSession {
|
||||
return {
|
||||
id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
agent: 'codex',
|
||||
sessionId: 'remote-session',
|
||||
title: 'remote-session',
|
||||
cwd: '/home/dev/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/home/dev/remote-session.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
||||
modifiedAt: '2026-08-02T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: 'codex resume remote-session',
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import {
|
||||
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult,
|
||||
type AiVaultScanIssue
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh'
|
||||
import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { parseAiVaultListResult } from './session-list-result-validation'
|
||||
import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results'
|
||||
|
||||
/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone;
|
||||
* `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */
|
||||
export type SshAiVaultScanBudget = {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
relayTimeoutMs?: number
|
||||
}
|
||||
|
||||
// Why: a relay that was given a real scan budget and still timed out will not
|
||||
// answer faster over the slower filesystem crawl, so that leg reports a host
|
||||
// issue. A relay cut short below this never got a fair attempt — those fall
|
||||
// back rather than emptying a healthy host's sessions (#12178 follow-up).
|
||||
const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000
|
||||
|
||||
export async function scanSshAiVaultSessions(
|
||||
targetId: string,
|
||||
args?: AiVaultListArgs,
|
||||
options: SshAiVaultScanBudget = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toSshExecutionHostId(targetId)
|
||||
// Why: in `all` scope every host leg is awaited together, so an unexpected
|
||||
// throw here (not a caller cancellation) would discard the local results too.
|
||||
try {
|
||||
return await scanOneSshHost(targetId, executionHostId, args, options)
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error
|
||||
}
|
||||
return sshScanIssueResult(executionHostId, targetId, errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function scanOneSshHost(
|
||||
targetId: string,
|
||||
executionHostId: `ssh:${string}`,
|
||||
args: AiVaultListArgs | undefined,
|
||||
options: SshAiVaultScanBudget
|
||||
): Promise<AiVaultListResult> {
|
||||
const startedAt = Date.now()
|
||||
// Both legs scan the same capped set, so the fallback can't quietly scan more
|
||||
// paths — or skip the truncation notice — than the relay leg would.
|
||||
const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT
|
||||
let relayError: unknown
|
||||
const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs
|
||||
try {
|
||||
const params = {
|
||||
limit: args?.limit,
|
||||
...(args?.unlimited === true ? { unlimited: true } : {}),
|
||||
...(args?.force === true ? { force: true } : {}),
|
||||
scopePaths,
|
||||
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
|
||||
}
|
||||
const relayResult =
|
||||
options.signal || relayTimeoutMs !== undefined
|
||||
? await requestActiveSshAiVaultSessionList(targetId, params, {
|
||||
signal: options.signal,
|
||||
timeoutMs: relayTimeoutMs
|
||||
})
|
||||
: await requestActiveSshAiVaultSessionList(targetId, params)
|
||||
if (relayResult !== null) {
|
||||
return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
isSshMuxRequestTimeoutError(error) &&
|
||||
(relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS)
|
||||
) {
|
||||
return sshScanIssueResult(executionHostId, targetId, errorMessage(error))
|
||||
}
|
||||
relayError = error
|
||||
}
|
||||
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
|
||||
const provider = getSshFilesystemProvider(targetId)
|
||||
if (!hostInfo || !provider) {
|
||||
return sshScanIssueResult(
|
||||
executionHostId,
|
||||
targetId,
|
||||
relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
)
|
||||
}
|
||||
// Why: `timeoutMs` bounded only the relay round trip, so a host on a relay
|
||||
// without the list method fell through to the unbounded desktop crawl and one
|
||||
// stalled SSH file stream could hold every other host's results hostage.
|
||||
const fallbackResult = await scanRemoteSessionsWithinBudget({
|
||||
scan: (signal) =>
|
||||
scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
scopePaths,
|
||||
signal
|
||||
}),
|
||||
signal: options.signal,
|
||||
remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt)
|
||||
})
|
||||
if (!fallbackResult) {
|
||||
return sshScanIssueResult(
|
||||
executionHostId,
|
||||
targetId,
|
||||
`Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.`
|
||||
)
|
||||
}
|
||||
const scopeIssues = scopePathsTruncated
|
||||
? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)]
|
||||
: []
|
||||
// An empty remote home and "the relay method failed and the crawl found
|
||||
// nothing" look identical, so a fallback that recovered nothing still reports
|
||||
// the relay error instead of presenting a broken relay as an empty host.
|
||||
if (!relayError || fallbackResult.sessions.length > 0) {
|
||||
return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] }
|
||||
}
|
||||
return {
|
||||
...fallbackResult,
|
||||
issues: [
|
||||
...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues,
|
||||
...fallbackResult.issues,
|
||||
...scopeIssues
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Budget left for the legacy crawl after the relay attempt spent part of it. */
|
||||
function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null {
|
||||
if (timeoutMs === undefined) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, timeoutMs - (Date.now() - startedAt))
|
||||
}
|
||||
|
||||
/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is
|
||||
* spent. Caller cancellation still propagates as an AbortError. */
|
||||
async function scanRemoteSessionsWithinBudget(args: {
|
||||
scan: (signal?: AbortSignal) => Promise<AiVaultListResult>
|
||||
signal?: AbortSignal
|
||||
remainingMs: number | null
|
||||
}): Promise<AiVaultListResult | null> {
|
||||
const remainingMs = args.remainingMs
|
||||
if (remainingMs === null) {
|
||||
return args.scan(args.signal)
|
||||
}
|
||||
if (args.signal?.aborted) {
|
||||
throw createAiVaultScanCancelledError()
|
||||
}
|
||||
if (remainingMs === 0) {
|
||||
return null
|
||||
}
|
||||
const controller = new AbortController()
|
||||
return new Promise<AiVaultListResult | null>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (settle: () => void): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
args.signal?.removeEventListener('abort', onCallerAbort)
|
||||
settle()
|
||||
}
|
||||
const onCallerAbort = (): void => {
|
||||
controller.abort()
|
||||
finish(() => reject(createAiVaultScanCancelledError()))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort()
|
||||
finish(() => resolve(null))
|
||||
}, remainingMs)
|
||||
args.signal?.addEventListener('abort', onCallerAbort, { once: true })
|
||||
if (args.signal?.aborted) {
|
||||
onCallerAbort()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const scan = args.scan(controller.signal)
|
||||
// The SSH provider may ignore abort; observe any eventual rejection after
|
||||
// this caller has already returned its timeout or cancellation result.
|
||||
void scan.then(
|
||||
(result) => finish(() => resolve(result)),
|
||||
(error: unknown) => finish(() => reject(error))
|
||||
)
|
||||
} catch (error) {
|
||||
finish(() => reject(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors the notice the relay leg appends so both legs report the same cap.
|
||||
function scopeTruncationIssue(
|
||||
executionHostId: `ssh:${string}`,
|
||||
remoteHome: string
|
||||
): AiVaultScanIssue {
|
||||
return {
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: remoteHome,
|
||||
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
|
||||
}
|
||||
}
|
||||
|
||||
function sshScanIssueResult(
|
||||
executionHostId: `ssh:${string}`,
|
||||
targetId: string,
|
||||
message: string
|
||||
): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({ executionHostId, path: targetId, message })
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: 'Agent Session History scan failed on the SSH target.'
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { aiVaultScanIssueResult } from '../ai-vault/session-list-results'
|
||||
|
||||
export type AiVaultHostDiscoveryResult<T> = {
|
||||
hostInfos: readonly T[]
|
||||
issue?: AiVaultListResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: enumerating runtime/SSH hosts reads live session state and can throw. In
|
||||
* 'all' scope that rejection would take down the whole Promise.all and drop the
|
||||
* local sessions too, so a broken enumerator degrades to one issue row instead.
|
||||
*/
|
||||
export function discoverAiVaultHosts<T>(
|
||||
enumerate: () => readonly T[],
|
||||
args: { path: string; fallbackMessage: string }
|
||||
): AiVaultHostDiscoveryResult<T> {
|
||||
try {
|
||||
return { hostInfos: enumerate() }
|
||||
} catch (error) {
|
||||
return {
|
||||
hostInfos: [],
|
||||
issue: aiVaultScanIssueResult({
|
||||
path: args.path,
|
||||
message: error instanceof Error ? error.message : args.fallbackMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
aiVaultSessionDepthCovers,
|
||||
truncateAiVaultListResult,
|
||||
type AiVaultSessionDepth
|
||||
} from '../../shared/ai-vault-session-depth'
|
||||
|
||||
export const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
|
||||
type CachedHostLeg = {
|
||||
depth: AiVaultSessionDepth
|
||||
result: AiVaultListResult
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
// Why: the merged result is only cacheable when every host answered, so one
|
||||
// flaky host used to force a full rescan of every healthy host on each panel
|
||||
// open. Legs are cached individually and the failing host is the only one
|
||||
// rescanned.
|
||||
const cachedHostLegs = new Map<string, CachedHostLeg>()
|
||||
|
||||
/** Serves one host's leg from the per-host TTL cache, and stores it only when
|
||||
* that host answered without a host issue — a failing host is retried on the
|
||||
* next open while its healthy neighbours stay cached. */
|
||||
export async function scanHostLegWithCache(args: {
|
||||
cacheKey: string
|
||||
depth: AiVaultSessionDepth
|
||||
scopePaths?: readonly string[]
|
||||
force: boolean
|
||||
scan: () => Promise<AiVaultListResult>
|
||||
}): Promise<AiVaultListResult> {
|
||||
const now = Date.now()
|
||||
const cached = cachedHostLegs.get(args.cacheKey)
|
||||
if (
|
||||
!args.force &&
|
||||
cached &&
|
||||
cached.expiresAt > now &&
|
||||
aiVaultSessionDepthCovers(cached.depth, args.depth)
|
||||
) {
|
||||
return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths)
|
||||
}
|
||||
const result = await args.scan()
|
||||
if (result.issues.some((issue) => issue.kind === 'host')) {
|
||||
const current = cachedHostLegs.get(args.cacheKey)
|
||||
if (!current || current.expiresAt <= Date.now()) {
|
||||
cachedHostLegs.delete(args.cacheKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
pruneExpiredHostLegs(now)
|
||||
const current = cachedHostLegs.get(args.cacheKey)
|
||||
if (
|
||||
!args.force &&
|
||||
current &&
|
||||
current.expiresAt > Date.now() &&
|
||||
aiVaultSessionDepthCovers(current.depth, args.depth)
|
||||
) {
|
||||
return result
|
||||
}
|
||||
cachedHostLegs.set(args.cacheKey, {
|
||||
depth: args.depth,
|
||||
result,
|
||||
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function pruneExpiredHostLegs(now: number): void {
|
||||
for (const [cacheKey, entry] of cachedHostLegs) {
|
||||
if (entry.expiresAt <= now) {
|
||||
cachedHostLegs.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAiVaultHostLegCacheForTests(): void {
|
||||
cachedHostLegs.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
isAiVaultScanCancelledError,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
abandonRemoteSessionScanOnCancel,
|
||||
throwIfAiVaultScanCancelled
|
||||
} from '../ai-vault/ai-vault-scan-cancellation'
|
||||
import { aiVaultScanIssueResult } from '../ai-vault/session-list-results'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
executionHostId: `runtime:${string}`
|
||||
}
|
||||
|
||||
export type RuntimeAiVaultScanOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type RuntimeAiVaultScanner = (
|
||||
environmentId: string,
|
||||
args: AiVaultListArgs,
|
||||
options?: RuntimeAiVaultScanOptions
|
||||
) => Promise<AiVaultListResult>
|
||||
|
||||
/**
|
||||
* Why: an unreachable Orca server must cost this host's row, not the whole
|
||||
* multi-host list, so every failure except cancellation degrades to an issue.
|
||||
*/
|
||||
export async function scanRuntimeAiVaultSessions(args: {
|
||||
hostInfo: RuntimeAiVaultHostInfo
|
||||
scanner: RuntimeAiVaultScanner | undefined
|
||||
listArgs?: AiVaultListArgs
|
||||
options?: RuntimeAiVaultScanOptions & { signal?: AbortSignal }
|
||||
}): Promise<AiVaultListResult> {
|
||||
const { signal, ...scannerOptions } = args.options ?? {}
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (!args.scanner) {
|
||||
return runtimeScanIssueResult(
|
||||
args.hostInfo,
|
||||
'Agent Session History is not available for this execution host.'
|
||||
)
|
||||
}
|
||||
try {
|
||||
return await abandonRemoteSessionScanOnCancel(
|
||||
args.scanner(args.hostInfo.environmentId, runtimeScanArgs(args.hostInfo, args.listArgs), {
|
||||
...scannerOptions
|
||||
}),
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
// RPC rejections keep the message but lose Error.name, so classify on both.
|
||||
if (isAiVaultScanCancelledError(error)) {
|
||||
throw error
|
||||
}
|
||||
return runtimeScanIssueResult(
|
||||
args.hostInfo,
|
||||
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional keys are copied only when present so the RPC schema never sees an
|
||||
// explicit undefined for limit/force/scopePaths.
|
||||
function runtimeScanArgs(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
listArgs: AiVaultListArgs | undefined
|
||||
): AiVaultListArgs {
|
||||
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
|
||||
if (listArgs?.limit !== undefined) {
|
||||
scanArgs.limit = listArgs.limit
|
||||
}
|
||||
if (listArgs?.unlimited !== undefined) {
|
||||
scanArgs.unlimited = listArgs.unlimited
|
||||
}
|
||||
if (listArgs?.force !== undefined) {
|
||||
scanArgs.force = listArgs.force
|
||||
}
|
||||
if (listArgs?.scopePaths !== undefined) {
|
||||
scanArgs.scopePaths = listArgs.scopePaths
|
||||
}
|
||||
return scanArgs
|
||||
}
|
||||
|
||||
function runtimeScanIssueResult(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
message: string
|
||||
): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
path: hostInfo.environmentId,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
scanRuntimeAiVaultSessions: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn(),
|
||||
requestActiveSshAiVaultSessionList: vi.fn(),
|
||||
ipcHandle: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } }))
|
||||
vi.mock('../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
}))
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
}))
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: vi.fn(),
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH unavailable',
|
||||
getSshFilesystemProvider: mocks.getSshFilesystemProvider
|
||||
}))
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
|
||||
}))
|
||||
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
const EMPTY_RESULT: AiVaultListResult = {
|
||||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-27T00:00:00.000Z'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
|
||||
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo())
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo()])
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('Agent Session History scan coalescing', () => {
|
||||
it.each([
|
||||
['local', mocks.scanAiVaultSessions],
|
||||
['runtime:remote-server', mocks.scanRuntimeAiVaultSessions]
|
||||
] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => {
|
||||
let resolveScan: ((result: AiVaultListResult) => void) | undefined
|
||||
scan.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
registerRuntimeHost()
|
||||
const firstController = new AbortController()
|
||||
const first = _internals.listAiVaultSessions(
|
||||
{ executionHostScope: scope },
|
||||
{ signal: firstController.signal }
|
||||
)
|
||||
const second = _internals.listAiVaultSessions({ executionHostScope: scope })
|
||||
await vi.waitFor(() => expect(resolveScan).toBeDefined())
|
||||
|
||||
firstController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(scan).toHaveBeenCalledTimes(1)
|
||||
resolveScan?.(EMPTY_RESULT)
|
||||
await expect(second).resolves.toEqual(EMPTY_RESULT)
|
||||
})
|
||||
|
||||
it('coalesces every all-host leg while isolating caller cancellation', async () => {
|
||||
let resolveRuntime: ((result: AiVaultListResult) => void) | undefined
|
||||
mocks.scanRuntimeAiVaultSessions.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRuntime = resolve
|
||||
})
|
||||
)
|
||||
registerRuntimeHost()
|
||||
const controller = new AbortController()
|
||||
|
||||
const first = _internals.listAiVaultSessions(
|
||||
{ executionHostScope: 'all' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
const second = _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
await vi.waitFor(() => expect(resolveRuntime).toBeDefined())
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
controller.abort()
|
||||
await firstRejection
|
||||
resolveRuntime?.(EMPTY_RESULT)
|
||||
await expect(second).resolves.toMatchObject({ sessions: [], issues: [] })
|
||||
})
|
||||
|
||||
it('keeps a shared multi-window scan alive when one window cancels', async () => {
|
||||
let resolveRelay: ((result: AiVaultListResult) => void) | undefined
|
||||
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRelay = resolve
|
||||
})
|
||||
)
|
||||
registerAiVaultHandlers()
|
||||
const list = ipcHandler('aiVault:listSessions')
|
||||
const cancel = ipcHandler('aiVault:cancelListSessions')
|
||||
const firstEvent = { sender: { id: 1 } }
|
||||
const secondEvent = { sender: { id: 2 } }
|
||||
const first = list(firstEvent, {
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
requestToken: 'scan'
|
||||
}) as Promise<AiVaultListResult>
|
||||
const second = list(secondEvent, {
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
requestToken: 'scan'
|
||||
}) as Promise<AiVaultListResult>
|
||||
await vi.waitFor(() => expect(resolveRelay).toBeDefined())
|
||||
|
||||
cancel(firstEvent, { requestToken: 'scan' })
|
||||
|
||||
// Electron logs every rejected handler, so a cancelled scan resolves instead.
|
||||
await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] })
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1)
|
||||
resolveRelay?.(EMPTY_RESULT)
|
||||
await expect(second).resolves.toEqual(EMPTY_RESULT)
|
||||
})
|
||||
|
||||
it('reports a real scan failure as a host issue rather than cancellation', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed'))
|
||||
mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed'))
|
||||
registerAiVaultHandlers()
|
||||
const list = ipcHandler('aiVault:listSessions')
|
||||
|
||||
// SSH host legs convert unexpected throws into scan issues so an `all`
|
||||
// multi-host list still returns the other hosts' sessions.
|
||||
const result = await list(
|
||||
{ sender: { id: 1 } },
|
||||
{ executionHostScope: 'ssh:dev-box', requestToken: 'scan' }
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
sessions: [],
|
||||
issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })]
|
||||
})
|
||||
expect(result).not.toHaveProperty('cancelled')
|
||||
})
|
||||
|
||||
it('still rejects the handler when a scan fails for a non-cancellation reason', async () => {
|
||||
mocks.scanAiVaultSessions.mockRejectedValue(new Error('transcript root is unreadable'))
|
||||
registerAiVaultHandlers()
|
||||
const list = ipcHandler('aiVault:listSessions')
|
||||
|
||||
await expect(
|
||||
list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' })
|
||||
).rejects.toThrow('transcript root is unreadable')
|
||||
})
|
||||
|
||||
it('re-joins a preempted same-scope caller onto the forced refresh', async () => {
|
||||
const signals: AbortSignal[] = []
|
||||
let resolveForced: ((result: AiVaultListResult) => void) | undefined
|
||||
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
|
||||
(_targetId, _params, options: { signal: AbortSignal }) => {
|
||||
signals.push(options.signal)
|
||||
return new Promise((resolve) => {
|
||||
if (signals.length === 1) {
|
||||
options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
|
||||
} else {
|
||||
resolveForced = resolve
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1))
|
||||
|
||||
const forced = _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
force: true
|
||||
})
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(2))
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
resolveForced?.(EMPTY_RESULT)
|
||||
// Another window's Refresh must not surface as this caller's cancellation.
|
||||
await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
|
||||
})
|
||||
})
|
||||
|
||||
function registerRuntimeHost(): void {
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [
|
||||
{ environmentId: 'remote-server', executionHostId: 'runtime:remote-server' }
|
||||
],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
}
|
||||
|
||||
function hostInfo() {
|
||||
return {
|
||||
targetId: 'dev-box',
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
}
|
||||
}
|
||||
|
||||
function ipcHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel)
|
||||
if (!registration) {
|
||||
throw new Error(`${channel} was not registered`)
|
||||
}
|
||||
return registration[1]
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
|
|
@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
|||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn(),
|
||||
requestActiveSshAiVaultSessionList: vi.fn(),
|
||||
ipcHandle: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -47,7 +49,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
|||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
|
||||
}))
|
||||
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
|
|
@ -66,6 +69,7 @@ beforeEach(() => {
|
|||
result([session('runtime:remote-server', 'runtime-session')])
|
||||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
})
|
||||
|
|
@ -101,11 +105,160 @@ describe('listAiVaultSessions host routing', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('uses one target-side relay scan when the SSH relay supports it', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(
|
||||
result([session('local', 'remote-session')])
|
||||
)
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
|
||||
'dev-box',
|
||||
{
|
||||
limit: undefined,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
},
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(scanned.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
id: expect.stringContaining('ssh:dev-box:')
|
||||
})
|
||||
})
|
||||
|
||||
it('marks oversized project scopes when sending their bounded relay form', async () => {
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([]))
|
||||
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths
|
||||
})
|
||||
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
|
||||
'dev-box',
|
||||
{
|
||||
limit: undefined,
|
||||
scopePaths: scopePaths.slice(0, 64),
|
||||
scopePathsTruncated: true
|
||||
},
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
})
|
||||
|
||||
it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => {
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths
|
||||
})
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) })
|
||||
)
|
||||
expect(scanned.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'scope',
|
||||
message: expect.stringContaining('first 64 project paths')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('coalesces concurrent cancellable requests into one scan', async () => {
|
||||
let resolveRelay: (() => void) | undefined
|
||||
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRelay = () => resolve(result([]))
|
||||
})
|
||||
)
|
||||
const args = { executionHostScope: 'ssh:dev-box' as const }
|
||||
|
||||
const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal })
|
||||
const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal })
|
||||
await vi.waitFor(() => expect(resolveRelay).toBeDefined())
|
||||
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1)
|
||||
resolveRelay?.()
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not start a second remote crawl after the relay scan budget expires', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box'
|
||||
})
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(scanned.sessions).toEqual([])
|
||||
expect(scanned.issues[0]?.message).toContain('timed out')
|
||||
})
|
||||
|
||||
it('does not cache a host-level relay failure', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
|
||||
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to the filesystem crawl after a non-timeout relay failure', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(
|
||||
new Error('Invalid aiVault.listSessions response')
|
||||
)
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box'
|
||||
})
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
|
||||
})
|
||||
|
||||
it('falls back when a nonempty relay sessions array contains no valid rows', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({
|
||||
sessions: [{ id: 42 }],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-27T00:00:00.000Z'
|
||||
})
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
|
||||
})
|
||||
|
||||
it('uses the relay scan without requiring the fallback filesystem provider', async () => {
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(
|
||||
result([session('local', 'remote-session')])
|
||||
)
|
||||
|
||||
const scanned = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box'
|
||||
})
|
||||
|
||||
expect(scanned.sessions).toHaveLength(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('merges local plus connected SSH targets for all hosts', async () => {
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
|
||||
'dev-box',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ timeoutMs: 15_000 })
|
||||
)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
})
|
||||
|
||||
|
|
@ -159,6 +312,50 @@ describe('listAiVaultSessions host routing', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('keeps SSH results when the local scan itself throws', async () => {
|
||||
// Why: `all` awaits every leg together, so an unguarded local throw (parse
|
||||
// cache load, WSL home resolution) would discard every host's sessions.
|
||||
mocks.scanAiVaultSessions.mockRejectedValue(new Error('session parse cache is corrupt'))
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box'])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'local',
|
||||
kind: 'host',
|
||||
path: 'this computer',
|
||||
message: 'session parse cache is corrupt'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local results when SSH host discovery fails', async () => {
|
||||
mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => {
|
||||
throw new Error('relay session map is unavailable')
|
||||
})
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local'])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
agent: 'codex',
|
||||
path: 'SSH hosts',
|
||||
message: 'relay session map is unavailable'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps direct runtime host scans on the normal runtime timeout', async () => {
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [],
|
||||
|
|
@ -205,6 +402,63 @@ describe('listAiVaultSessions host routing', () => {
|
|||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('caches completed SSH scans by host and workspace scope', async () => {
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b']
|
||||
})
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a']
|
||||
})
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('serves lower SSH depths from a larger completed scan', async () => {
|
||||
const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] }
|
||||
await _internals.listAiVaultSessions({ ...base, limit: 1000 })
|
||||
await _internals.listAiVaultSessions({ ...base, limit: 250 })
|
||||
await _internals.listAiVaultSessions({ ...base, limit: 500 })
|
||||
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('threads renderer cancellation into the SSH relay request', async () => {
|
||||
let relaySignal: AbortSignal | undefined
|
||||
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
|
||||
(_targetId, _params, options: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
relaySignal = options.signal
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('cancelled')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
registerAiVaultHandlers()
|
||||
const event = { sender: { id: 7 } }
|
||||
const pending = getIpcHandler('aiVault:listSessions')(event, {
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
requestToken: 'scan-1'
|
||||
})
|
||||
await vi.waitFor(() => expect(relaySignal).toBeDefined())
|
||||
|
||||
await getIpcHandler('aiVault:cancelListSessions')(event, {
|
||||
requestToken: 'scan-1'
|
||||
})
|
||||
|
||||
expect(relaySignal?.aborted).toBe(true)
|
||||
// Resolved, not rejected: Electron logs every rejected handler, and a
|
||||
// superseded scan is normal control flow rather than a failure.
|
||||
await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSessionResume IPC', () => {
|
||||
|
|
@ -280,6 +534,14 @@ function getPrepareSessionResumeHandler(): (
|
|||
return registration[1]
|
||||
}
|
||||
|
||||
function getIpcHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel)
|
||||
if (!registration) {
|
||||
throw new Error(`${channel} was not registered`)
|
||||
}
|
||||
return registration[1]
|
||||
}
|
||||
|
||||
describe('listAiVaultSubagentSessions gating', () => {
|
||||
const claudeRoot = join(homedir(), '.claude', 'projects')
|
||||
|
||||
|
|
@ -365,6 +627,14 @@ function hostInfo(targetId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on
|
||||
* the message text. */
|
||||
function relayTimeoutError(): Error {
|
||||
return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), {
|
||||
code: SSH_MUX_REQUEST_TIMEOUT_CODE
|
||||
})
|
||||
}
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: new Date().toISOString() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,24 @@ import {
|
|||
resetAiVaultSessionListCacheForTests,
|
||||
type AiVaultSessionSources
|
||||
} from '../ai-vault/cached-session-list'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
|
||||
import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
|
||||
import type {
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultSubagentListArgs,
|
||||
AiVaultSubagentListResult
|
||||
import {
|
||||
aiVaultScanIssueResult,
|
||||
cancelledAiVaultListResult,
|
||||
mergeAiVaultListResults
|
||||
} from '../ai-vault/session-list-results'
|
||||
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
|
||||
import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator'
|
||||
import {
|
||||
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
||||
isAiVaultScanCancelledError,
|
||||
type AiVaultFirstUserPromptArgs,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult,
|
||||
type AiVaultSubagentListArgs,
|
||||
type AiVaultSubagentListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read'
|
||||
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
|
||||
|
|
@ -29,126 +36,150 @@ import {
|
|||
toSshExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../shared/execution-host'
|
||||
import { getActiveSshAiVaultHostInfos } from './ssh'
|
||||
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
|
||||
import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
|
||||
scanRuntimeAiVaultSessions,
|
||||
type RuntimeAiVaultHostInfo,
|
||||
type RuntimeAiVaultScanner
|
||||
} from './ai-vault-runtime-scan'
|
||||
import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache'
|
||||
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
|
||||
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
|
||||
// Why: a remote home with many agent roots routinely needs seconds to walk,
|
||||
// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the
|
||||
// all-hosts view; the relay gets a real scan budget and the whole leg (relay
|
||||
// attempt plus any legacy crawl) stays bounded so one host can't hold the
|
||||
// merge open.
|
||||
const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000
|
||||
const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
|
||||
|
||||
type AiVaultHandlerOptions = AiVaultSessionSources &
|
||||
AiVaultResumeHandlerOptions & {
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: (
|
||||
environmentId: string,
|
||||
args: AiVaultListArgs,
|
||||
options?: RuntimeAiVaultScanOptions
|
||||
) => Promise<AiVaultListResult>
|
||||
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
|
||||
}
|
||||
|
||||
type RuntimeAiVaultScanOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
type CachedAiVaultList = {
|
||||
key: string
|
||||
result: AiVaultListResult
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
executionHostId: `runtime:${string}`
|
||||
}
|
||||
|
||||
let cachedList: CachedAiVaultList | null = null
|
||||
let inflightList: Promise<AiVaultListResult> | null = null
|
||||
let inflightKey: string | null = null
|
||||
let scanCoordinator = new AiVaultScanCoordinator()
|
||||
let handlerOptions: AiVaultHandlerOptions = {}
|
||||
const listCancellations = createSenderScopedRequestCancellations()
|
||||
|
||||
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
async function listAiVaultSessions(
|
||||
args?: AiVaultListArgs,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Why: local-scope scans go straight to the shared cache module (also used by
|
||||
// the runtime RPC method), so the desktop panel and a paired mobile client
|
||||
// never double-scan the same transcripts; the cache below only has to dedupe
|
||||
// the multi-host (ssh/runtime/all) merges that exist on the desktop side.
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args)
|
||||
}
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
// A scanner consumes at most 64 paths, so smaller equivalent workspace sets
|
||||
// can share a snapshot regardless of which worktree was selected first.
|
||||
const scopePaths = args?.scopePaths ?? []
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? [],
|
||||
scopePaths:
|
||||
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
|
||||
? [...new Set(scopePaths)].sort()
|
||||
: scopePaths,
|
||||
executionHostScope
|
||||
})
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
// transcripts; explicit refreshes bypass the cache but not an active scan.
|
||||
if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) {
|
||||
return cachedList.result
|
||||
}
|
||||
if (inflightList && inflightKey === key) {
|
||||
return inflightList
|
||||
}
|
||||
|
||||
inflightKey = key
|
||||
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
|
||||
.then((result) => {
|
||||
cachedList = {
|
||||
key,
|
||||
result,
|
||||
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scanKey = JSON.stringify({ key, depth })
|
||||
// Why: every renderer request carries its own cancellation signal, so
|
||||
// coalescing has to survive them — the coordinator hands all same-key callers
|
||||
// one scan and only aborts it once every one of them has cancelled.
|
||||
return scanCoordinator.run({
|
||||
key: scanKey,
|
||||
force: args?.force,
|
||||
signal: options.signal,
|
||||
start: (scanSignal) => {
|
||||
const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key)
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scan()
|
||||
}
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
// Only clear tracking if it still refers to this request: a concurrent
|
||||
// different-scope scan may have replaced it and must stay dedupable.
|
||||
if (inflightKey === key) {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
}
|
||||
})
|
||||
return inflightList
|
||||
return scanHostLegWithCache({
|
||||
cacheKey: key,
|
||||
depth,
|
||||
scopePaths,
|
||||
force: args?.force === true,
|
||||
scan
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function scanAiVaultSessionsByHostScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
executionHostScope: ExecutionHostScope
|
||||
executionHostScope: ExecutionHostScope,
|
||||
signal?: AbortSignal,
|
||||
cacheKey = ''
|
||||
): Promise<AiVaultListResult> {
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scopePaths = args?.scopePaths ?? []
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args, signal)
|
||||
}
|
||||
if (executionHostScope === 'all') {
|
||||
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
|
||||
const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : []
|
||||
const sshHosts = getActiveSshAiVaultHostInfosResult()
|
||||
const runtimeResults = [
|
||||
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
|
||||
...(sshHosts.issue ? [sshHosts.issue] : [])
|
||||
]
|
||||
const scannedResults = await Promise.all([
|
||||
scanLocalAiVaultSessions(args),
|
||||
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args)
|
||||
scanLocalAiVaultSessionsForAllScope(args, signal),
|
||||
...sshHosts.hostInfos.map((hostInfo) =>
|
||||
scanHostLegWithCache({
|
||||
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
|
||||
depth,
|
||||
scopePaths,
|
||||
force: args?.force === true,
|
||||
scan: () =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args, {
|
||||
signal,
|
||||
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS,
|
||||
relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
),
|
||||
...runtimeHosts.hostInfos.map((hostInfo) =>
|
||||
scanRuntimeAiVaultSessions(hostInfo, args, {
|
||||
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
|
||||
scanHostLegWithCache({
|
||||
cacheKey: `${cacheKey}|${hostInfo.executionHostId}`,
|
||||
depth,
|
||||
scopePaths,
|
||||
force: args?.force === true,
|
||||
scan: () =>
|
||||
scanRuntimeAiVaultSessions({
|
||||
hostInfo,
|
||||
scanner: handlerOptions.scanRuntimeAiVaultSessions,
|
||||
listArgs: args,
|
||||
options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS }
|
||||
})
|
||||
})
|
||||
)
|
||||
])
|
||||
return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit)
|
||||
return mergeAiVaultListResults(
|
||||
[...scannedResults, ...runtimeResults],
|
||||
args?.limit,
|
||||
args?.unlimited
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = parseExecutionHostId(executionHostScope)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return scanSshAiVaultSessions(parsed.targetId, args)
|
||||
return scanSshAiVaultSessions(parsed.targetId, args, { signal })
|
||||
}
|
||||
if (parsed?.kind === 'runtime') {
|
||||
return scanRuntimeAiVaultSessions(
|
||||
{
|
||||
return scanRuntimeAiVaultSessions({
|
||||
hostInfo: {
|
||||
environmentId: parsed.environmentId,
|
||||
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
|
||||
},
|
||||
args
|
||||
)
|
||||
scanner: handlerOptions.scanRuntimeAiVaultSessions,
|
||||
listArgs: args,
|
||||
options: { signal }
|
||||
})
|
||||
}
|
||||
|
||||
return aiVaultScanIssueResult({
|
||||
|
|
@ -158,118 +189,57 @@ async function scanAiVaultSessionsByHostScope(
|
|||
})
|
||||
}
|
||||
|
||||
function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] {
|
||||
return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? []
|
||||
}
|
||||
|
||||
function getActiveRuntimeAiVaultHostInfosResult(): {
|
||||
hostInfos: readonly RuntimeAiVaultHostInfo[]
|
||||
issue?: AiVaultListResult
|
||||
} {
|
||||
try {
|
||||
return { hostInfos: getActiveRuntimeAiVaultHostInfos() }
|
||||
} catch (error) {
|
||||
return {
|
||||
hostInfos: [],
|
||||
issue: runtimeHostDiscoveryIssueResult(
|
||||
error instanceof Error ? error.message : 'Runtime hosts are unavailable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRuntimeAiVaultSessions(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
args?: AiVaultListArgs,
|
||||
options: RuntimeAiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const scanner = handlerOptions.scanRuntimeAiVaultSessions
|
||||
if (!scanner) {
|
||||
return runtimeScanIssueResult(
|
||||
hostInfo,
|
||||
'Agent Session History is not available for this execution host.'
|
||||
)
|
||||
}
|
||||
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
|
||||
if (args?.limit !== undefined) {
|
||||
scanArgs.limit = args.limit
|
||||
}
|
||||
if (args?.force !== undefined) {
|
||||
scanArgs.force = args.force
|
||||
}
|
||||
if (args?.scopePaths !== undefined) {
|
||||
scanArgs.scopePaths = args.scopePaths
|
||||
}
|
||||
try {
|
||||
return await scanner(hostInfo.environmentId, scanArgs, options)
|
||||
} catch (error) {
|
||||
return runtimeScanIssueResult(
|
||||
hostInfo,
|
||||
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeScanIssueResult(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
message: string
|
||||
): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
path: hostInfo.environmentId,
|
||||
message
|
||||
function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<RuntimeAiVaultHostInfo> {
|
||||
return discoverAiVaultHosts(() => handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [], {
|
||||
path: 'runtime environments',
|
||||
fallbackMessage: 'Runtime hosts are unavailable.'
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({ path: 'runtime environments', message })
|
||||
function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> {
|
||||
return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, {
|
||||
path: 'SSH hosts',
|
||||
fallbackMessage: 'SSH hosts are unavailable.'
|
||||
})
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
// Why: the SSH legs already degrade to an issue row so one bad host can't take
|
||||
// the shared Promise.all down; the local leg can throw too (parse-cache load,
|
||||
// WSL home resolution) and would otherwise discard every host's sessions.
|
||||
async function scanLocalAiVaultSessionsForAllScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<AiVaultListResult> {
|
||||
try {
|
||||
return await scanLocalAiVaultSessions(args, signal)
|
||||
} catch (error) {
|
||||
if (isAiVaultScanCancelledError(error)) {
|
||||
throw error
|
||||
}
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
path: 'this computer',
|
||||
message: error instanceof Error ? error.message : 'Local session scan failed.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(
|
||||
args?: AiVaultListArgs,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
// Why: the shared cache module owns codex-home/WSL sourcing and the local
|
||||
// scan cache, so the desktop IPC path and the runtime RPC method (mobile)
|
||||
// share one cache instance and one source of managed-Codex homes.
|
||||
return listCachedLocalAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
force: args?.force,
|
||||
scopePaths: args?.scopePaths
|
||||
})
|
||||
}
|
||||
|
||||
async function scanSshAiVaultSessions(
|
||||
targetId: string,
|
||||
args?: AiVaultListArgs
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toSshExecutionHostId(targetId)
|
||||
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
|
||||
const provider = getSshFilesystemProvider(targetId)
|
||||
if (!hostInfo || !provider) {
|
||||
return sshScanIssueResult({
|
||||
executionHostId,
|
||||
targetId,
|
||||
message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
})
|
||||
}
|
||||
return scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths
|
||||
})
|
||||
}
|
||||
|
||||
function sshScanIssueResult(args: {
|
||||
executionHostId: `ssh:${string}`
|
||||
targetId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: args.executionHostId,
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
})
|
||||
return listCachedLocalAiVaultSessions(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
force: args?.force,
|
||||
scopePaths: args?.scopePaths
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
|
|
@ -279,8 +249,32 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
|||
// WSL injection. The runtime also configures these sources from its deps
|
||||
// (serve-mode reachable); this desktop path supplies the same source.
|
||||
configureAiVaultSessionSources(options)
|
||||
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
|
||||
listAiVaultSessions(args)
|
||||
ipcMain.handle('aiVault:listSessions', async (event, args?: AiVaultListArgs) => {
|
||||
const requestToken =
|
||||
typeof args?.requestToken === 'string' && args.requestToken.length <= 128
|
||||
? args.requestToken
|
||||
: undefined
|
||||
const controller = listCancellations.begin(event, requestToken)
|
||||
try {
|
||||
return await listAiVaultSessions(args, { signal: controller?.signal })
|
||||
} catch (error) {
|
||||
// Why: superseding a scan is normal control flow, but Electron logs every
|
||||
// rejected handler — report it as a result so the log stays truthful.
|
||||
if (!isAiVaultScanCancelledError(error)) {
|
||||
throw error
|
||||
}
|
||||
return cancelledAiVaultListResult()
|
||||
} finally {
|
||||
listCancellations.finish(event, requestToken, controller)
|
||||
}
|
||||
})
|
||||
ipcMain.handle(
|
||||
'aiVault:cancelListSessions',
|
||||
(event, args: { requestToken?: string } | undefined): void => {
|
||||
if (typeof args?.requestToken === 'string' && args.requestToken.length <= 128) {
|
||||
listCancellations.cancel(event, args.requestToken)
|
||||
}
|
||||
}
|
||||
)
|
||||
registerAiVaultResumeHandler(options)
|
||||
ipcMain.handle(
|
||||
|
|
@ -335,9 +329,8 @@ async function listAiVaultSubagentSessions(
|
|||
}
|
||||
|
||||
function resetAiVaultCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
inflightKey = null
|
||||
resetAiVaultHostLegCacheForTests()
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
handlerOptions = {}
|
||||
// The local leg delegates to the shared cache module; reset it too so tests
|
||||
// never see a scan cached by an earlier case.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection'
|
|||
import { SshConnectionManager } from '../ssh/ssh-connection-manager'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
|
||||
import type { SshAiVaultRelayListParams } from '../../shared/ssh-ai-vault-relay'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
DetectedPort,
|
||||
|
|
@ -161,6 +162,21 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
|
|||
})
|
||||
}
|
||||
|
||||
export async function requestActiveSshAiVaultSessionList(
|
||||
targetId: string,
|
||||
params: SshAiVaultRelayListParams,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
||||
): Promise<unknown | null> {
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
return null
|
||||
}
|
||||
const session = activeSessions.get(targetId)
|
||||
if (!session) {
|
||||
throw new Error('SSH relay is not ready')
|
||||
}
|
||||
return session.requestAiVaultSessionList(params, options)
|
||||
}
|
||||
|
||||
function runTargetLifecycle(targetId: string, operation: () => Promise<void>): Promise<void> {
|
||||
const prior = targetLifecycleInFlight.get(targetId)
|
||||
const operationPromise = (async () => {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ describe('aiVault.listSessions params schema', () => {
|
|||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts Unlimited without applying the numeric cap', () => {
|
||||
const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000, unlimited: true })
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps scopePaths past the cap instead of rejecting', () => {
|
||||
// Why: uncapped producers (web client, pre-cap desktop parents) may exceed
|
||||
// the bound; scope paths only widen discovery, so truncation is safe.
|
||||
|
|
@ -181,6 +186,33 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('serves lower depths from a larger completed scan', async () => {
|
||||
await listAiVaultSessions({ limit: 1000 })
|
||||
await listAiVaultSessions({ limit: 250 })
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares a cache entry across equivalent scope path ordering', async () => {
|
||||
await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] })
|
||||
await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] })
|
||||
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards Unlimited without a numeric limit', async () => {
|
||||
const dispatcher = makeDispatcher()
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('aiVault.listSessions', { limit: 5000, unlimited: true })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: undefined, unlimited: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a newer different-key scan dedupable after an older scan resolves', async () => {
|
||||
// Why: the resolving scan's cleanup must not clear tracking a concurrent
|
||||
// different-key scan replaced, or re-requests start a duplicate rescan.
|
||||
|
|
|
|||
|
|
@ -23,27 +23,34 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri
|
|||
return z.NEVER
|
||||
})
|
||||
|
||||
export const AiVaultListSessionsParams = z.object({
|
||||
limit: z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
)
|
||||
.pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()]))
|
||||
.optional(),
|
||||
force: OptionalBoolean,
|
||||
scopePaths: z
|
||||
.array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
|
||||
// Why: clamp instead of reject — scope paths only ever widen discovery, and
|
||||
// rejecting would hard-break older/uncapped producers (web client, pre-cap
|
||||
// desktop parents) that send more than the bound.
|
||||
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
|
||||
.optional(),
|
||||
// Why: desktop/web callers name the runtime host they are addressing; mobile
|
||||
// omits it. The scan itself is host-local either way, so the id must never
|
||||
// change what is scanned — it only restamps the shared cached result.
|
||||
executionHostId: executionHostIdSchema.optional()
|
||||
})
|
||||
export const AiVaultListSessionsParams = z
|
||||
.object({
|
||||
limit: z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
)
|
||||
.pipe(z.union([z.number().int(), z.undefined()]))
|
||||
.optional(),
|
||||
unlimited: OptionalBoolean,
|
||||
force: OptionalBoolean,
|
||||
scopePaths: z
|
||||
.array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
|
||||
// Why: clamp instead of reject — scope paths only ever widen discovery, and
|
||||
// rejecting would hard-break older/uncapped producers (web client, pre-cap
|
||||
// desktop parents) that send more than the bound.
|
||||
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
|
||||
.optional(),
|
||||
// Why: desktop/web callers name the runtime host they are addressing; mobile
|
||||
// omits it. The scan itself is host-local either way, so the id must never
|
||||
// change what is scanned — it only restamps the shared cached result.
|
||||
executionHostId: executionHostIdSchema.optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) {
|
||||
ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' })
|
||||
}
|
||||
})
|
||||
|
||||
export const AiVaultPrepareSessionResumeParams = z.object({
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
|
|
@ -58,7 +65,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [
|
|||
params: AiVaultListSessionsParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
const result = await runtime.listAiVaultSessions({
|
||||
limit: params.limit,
|
||||
limit: params.unlimited ? undefined : params.limit,
|
||||
unlimited: params.unlimited,
|
||||
force: params.force,
|
||||
scopePaths: params.scopePaths
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,24 @@ const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1
|
|||
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
|
||||
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
|
||||
|
||||
// Why: callers branch on "the request timed out" (fall back to a slower path,
|
||||
// report a host issue). Matching the message text made every unrelated error
|
||||
// carrying the same phrase take the timeout branch.
|
||||
export const SSH_MUX_REQUEST_TIMEOUT_CODE = 'SSH_MUX_REQUEST_TIMEOUT'
|
||||
|
||||
function sshMuxRequestTimeoutError(method: string, timeoutMs: number): Error {
|
||||
return Object.assign(new Error(`Request "${method}" timed out after ${timeoutMs}ms`), {
|
||||
code: SSH_MUX_REQUEST_TIMEOUT_CODE
|
||||
})
|
||||
}
|
||||
|
||||
export function isSshMuxRequestTimeoutError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error as Error & { code?: unknown }).code === SSH_MUX_REQUEST_TIMEOUT_CODE
|
||||
)
|
||||
}
|
||||
|
||||
export class SshChannelMultiplexer {
|
||||
private decoder: FrameDecoder
|
||||
private transport: MultiplexerTransport
|
||||
|
|
@ -231,7 +249,7 @@ export class SshChannelMultiplexer {
|
|||
this.notify('rpc.cancel', { id })
|
||||
}
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
|
||||
reject(sshMuxRequestTimeoutError(method, timeoutMs))
|
||||
}, timeoutMs)
|
||||
|
||||
if (options?.signal) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notific
|
|||
import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
|
||||
import { isMethodNotFoundError } from './ssh-filesystem-stream-reader'
|
||||
import { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||||
|
|
@ -86,6 +87,11 @@ import {
|
|||
parseRemoteOrcaCliPostOutput
|
||||
} from './ssh-remote-orchestration-post-output'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS,
|
||||
type SshAiVaultRelayListParams
|
||||
} from '../../shared/ssh-ai-vault-relay'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import {
|
||||
|
|
@ -272,6 +278,7 @@ export class SshRelaySession {
|
|||
private currentConnection: SshConnection | null = null
|
||||
private hostPlatform: RemoteHostPlatform | null = null
|
||||
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
|
||||
private aiVaultListMethodSupported: boolean | null = null
|
||||
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
|
||||
private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget()
|
||||
private activePtyProviderGeneration: number | null = null
|
||||
|
|
@ -377,6 +384,33 @@ export class SshRelaySession {
|
|||
}
|
||||
}
|
||||
|
||||
async requestAiVaultSessionList(
|
||||
params: SshAiVaultRelayListParams,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
||||
): Promise<unknown | null> {
|
||||
if (this.aiVaultListMethodSupported === false) {
|
||||
return null
|
||||
}
|
||||
const mux = this.mux
|
||||
if (!mux || mux.isDisposed() || this._state !== 'ready') {
|
||||
throw new Error('SSH relay is not ready')
|
||||
}
|
||||
try {
|
||||
const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, {
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs ?? SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS
|
||||
})
|
||||
this.aiVaultListMethodSupported = true
|
||||
return result
|
||||
} catch (error) {
|
||||
if (isMethodNotFoundError(error)) {
|
||||
this.aiVaultListMethodSupported = false
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getPortScanner(): PortScanner | null {
|
||||
return this.portScanner
|
||||
}
|
||||
|
|
@ -395,6 +429,7 @@ export class SshRelaySession {
|
|||
throw new Error(`Cannot establish relay session in state: ${this._state}`)
|
||||
}
|
||||
this._state = 'deploying'
|
||||
this.aiVaultListMethodSupported = null
|
||||
this.currentConnection = conn
|
||||
|
||||
try {
|
||||
|
|
@ -528,6 +563,7 @@ export class SshRelaySession {
|
|||
this.abortController = abortController
|
||||
|
||||
this._state = 'reconnecting'
|
||||
this.aiVaultListMethodSupported = null
|
||||
this.currentConnection = conn
|
||||
|
||||
// Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer.
|
||||
|
|
|
|||
|
|
@ -890,6 +890,7 @@ export type OpenCodeUsageApi = {
|
|||
|
||||
export type AiVaultApi = {
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
cancelListSessions: (args: { requestToken: string }) => Promise<void>
|
||||
prepareSessionResume: (
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
) => Promise<AiVaultPrepareSessionResumeResult>
|
||||
|
|
|
|||
|
|
@ -4164,6 +4164,8 @@ const api = {
|
|||
aiVault: {
|
||||
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:listSessions', args),
|
||||
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('aiVault:cancelListSessions', args),
|
||||
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
|
||||
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import { SSH_AI_VAULT_LIST_SESSIONS_METHOD } from '../shared/ssh-ai-vault-relay'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
|
||||
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
|
||||
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('AiVaultHandler', () => {
|
||||
it('discovers and parses sessions entirely on the relay host', async () => {
|
||||
const remoteHome = await makeTemporaryHome()
|
||||
const transcriptPath = join(
|
||||
remoteHome,
|
||||
'.codex',
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'26',
|
||||
'rollout-test.jsonl'
|
||||
)
|
||||
await mkdir(dirname(transcriptPath), { recursive: true })
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-26T01:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-26T01:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Scan on the SSH target' }]
|
||||
}
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
limit: 20
|
||||
})) as AiVaultListResult
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'local',
|
||||
executionHostPlatform: 'linux',
|
||||
sessionId: 'ssh-session',
|
||||
title: 'Scan on the SSH target',
|
||||
filePath: transcriptPath
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds relay scan parameters before touching the target filesystem', async () => {
|
||||
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
})
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
||||
|
||||
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
limit: 50_000,
|
||||
scopePaths
|
||||
})
|
||||
|
||||
expect(scanRemoteSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionHostId: 'local',
|
||||
limit: 1000,
|
||||
scopePaths: scopePaths.slice(0, 64)
|
||||
})
|
||||
)
|
||||
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
scopePaths
|
||||
})) as AiVaultListResult
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'scope',
|
||||
message: expect.stringContaining('first 64 project paths')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards Unlimited without the relay numeric cap', async () => {
|
||||
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
})
|
||||
|
||||
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
limit: 50_000,
|
||||
unlimited: true
|
||||
})
|
||||
|
||||
expect(scanRemoteSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: undefined, unlimited: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('coalesces identical in-flight scans without coupling caller cancellation', async () => {
|
||||
let resolveScan: ((result: AiVaultListResult) => void) | undefined
|
||||
let sharedSignal: AbortSignal | undefined
|
||||
const scanRemoteSessions = vi.fn(
|
||||
(args: { signal?: AbortSignal }) =>
|
||||
new Promise<AiVaultListResult>((resolve) => {
|
||||
sharedSignal = args.signal
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
})
|
||||
const firstController = new AbortController()
|
||||
const first = dispatcher.call(
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
{ limit: 20 },
|
||||
firstController.signal
|
||||
)
|
||||
const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
||||
await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1))
|
||||
|
||||
firstController.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(sharedSignal?.aborted).toBe(false)
|
||||
|
||||
resolveScan?.(emptyResult())
|
||||
await expect(second).resolves.toEqual(emptyResult())
|
||||
})
|
||||
|
||||
it('re-joins a preempted relay caller onto the forced refresh', async () => {
|
||||
const signals: AbortSignal[] = []
|
||||
let resolveForced: ((result: AiVaultListResult) => void) | undefined
|
||||
const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => {
|
||||
signals.push(args.signal)
|
||||
return new Promise<AiVaultListResult>((resolve) => {
|
||||
if (signals.length === 1) {
|
||||
args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true })
|
||||
} else {
|
||||
resolveForced = resolve
|
||||
}
|
||||
})
|
||||
})
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
})
|
||||
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1))
|
||||
|
||||
const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
limit: 20,
|
||||
force: true
|
||||
})
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(2))
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
resolveForced?.(emptyResult())
|
||||
// The desktop caller that did not ask for a refresh still gets sessions.
|
||||
await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()])
|
||||
})
|
||||
|
||||
it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true })
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const dispatcher = createMockDispatcher()
|
||||
|
||||
expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow()
|
||||
|
||||
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('stops a relay-local scan when the owning request is cancelled', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
async function makeTemporaryHome(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-'))
|
||||
temporaryHomes.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
function emptyResult(): AiVaultListResult {
|
||||
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function createMockDispatcher(): {
|
||||
value: RelayDispatcher
|
||||
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
|
||||
} {
|
||||
const handlers = new Map<string, RequestHandler>()
|
||||
const value = {
|
||||
onRequest(method: string, handler: RequestHandler) {
|
||||
handlers.set(method, handler)
|
||||
}
|
||||
} as RelayDispatcher
|
||||
return {
|
||||
value,
|
||||
call(method, params, signal) {
|
||||
const handler = handlers.get(method)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler for ${method}`)
|
||||
}
|
||||
return handler(params, {
|
||||
clientId: 1,
|
||||
isStale: () => signal?.aborted ?? false,
|
||||
signal
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import {
|
||||
SSH_AI_VAULT_LIST_LIMIT_MAX,
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
|
||||
type SshAiVaultRelayListParams
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
|
||||
import { readRelayFileContent } from './fs-handler-file-read'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator'
|
||||
|
||||
type ScanRemoteSessions = typeof scanRemoteAiVaultSessions
|
||||
|
||||
type AiVaultHandlerOptions = {
|
||||
remoteHome?: string
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
scanRemoteSessions?: ScanRemoteSessions
|
||||
}
|
||||
|
||||
export class AiVaultHandler {
|
||||
private readonly remoteHome: string
|
||||
private readonly scanRemoteSessions: ScanRemoteSessions
|
||||
private readonly provider: RemoteSessionFilesystemProvider
|
||||
private readonly scanCoordinator = new AiVaultScanCoordinator()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) {
|
||||
this.remoteHome = options.remoteHome ?? homedir()
|
||||
this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions
|
||||
this.provider = createRelayAiVaultFilesystemProvider()
|
||||
const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform()
|
||||
// Why: an OS/arch this build has no path flavor for must not abort relay
|
||||
// startup — leaving the method unregistered soft-disables the feature and
|
||||
// the host falls back to its own SSH filesystem scan.
|
||||
if (!hostPlatform) {
|
||||
relayLogLine(
|
||||
`[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}`
|
||||
)
|
||||
return
|
||||
}
|
||||
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
|
||||
this.listSessions(hostPlatform, params, context.signal)
|
||||
)
|
||||
}
|
||||
|
||||
private async listSessions(
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
const params = normalizeSshAiVaultRelayListParams(rawParams)
|
||||
const result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
scopePathsTruncated: params.scopePathsTruncated
|
||||
}),
|
||||
force: params.force,
|
||||
signal,
|
||||
start: (scanSignal) =>
|
||||
this.scanRemoteSessions({
|
||||
provider: this.provider,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
remoteHome: this.remoteHome,
|
||||
hostPlatform,
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
signal: scanSignal
|
||||
})
|
||||
})
|
||||
if (!params.scopePathsTruncated) {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
issues: [
|
||||
...result.issues,
|
||||
{
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: this.remoteHome,
|
||||
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSshAiVaultRelayListParams(
|
||||
params: Record<string, unknown>
|
||||
): SshAiVaultRelayListParams {
|
||||
const rawLimit = params.limit
|
||||
const unlimited = params.unlimited === true
|
||||
const limit =
|
||||
!unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
|
||||
? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX)
|
||||
: undefined
|
||||
const scopePaths = Array.isArray(params.scopePaths)
|
||||
? params.scopePaths
|
||||
.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
typeof path === 'string' &&
|
||||
path.trim().length > 0 &&
|
||||
path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH
|
||||
)
|
||||
: undefined
|
||||
const scopePathsTruncated =
|
||||
params.scopePathsTruncated === true ||
|
||||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
return {
|
||||
...(unlimited ? { unlimited: true } : {}),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(params.force === true ? { force: true } : {}),
|
||||
...(scopePaths === undefined ? {} : { scopePaths }),
|
||||
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function currentRelayHostPlatform(): RemoteHostPlatform | null {
|
||||
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null
|
||||
}
|
||||
|
||||
function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider {
|
||||
return {
|
||||
async readDir(dirPath) {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
},
|
||||
readFile: readRelayFileContent,
|
||||
async stat(filePath) {
|
||||
const stats = await lstat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
mtime: stats.mtimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
nlink: stats.nlink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import { ExternalAutomationsHandler } from './external-automations-handler'
|
|||
import { PortScanHandler } from './port-scan-handler'
|
||||
import { AgentExecHandler } from './agent-exec-handler'
|
||||
import { WorkspaceSessionHandler } from './workspace-session-handler'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
import {
|
||||
|
|
@ -663,6 +664,9 @@ async function main(): Promise<void> {
|
|||
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
|
||||
void _workspaceSessionHandler
|
||||
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher)
|
||||
void _aiVaultHandler
|
||||
|
||||
// Why: relay-hosted plugin provisioning is a later phase. Register the
|
||||
// enforcement boundary now with no consented identities or runtime services.
|
||||
registerRelayPluginHostCallHandlers(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from './ai-vault-host-scope'
|
||||
import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options'
|
||||
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
|
||||
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
|
||||
|
||||
export default function AiVaultPanel(): React.JSX.Element {
|
||||
const activeWorktreeId = useActiveWorktreeId()
|
||||
|
|
@ -77,9 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
sort,
|
||||
group,
|
||||
hideEmptySessions,
|
||||
sessionLimit,
|
||||
setSort,
|
||||
setGroup,
|
||||
setHideEmptySessions,
|
||||
setSessionLimit,
|
||||
setAgentEnabled,
|
||||
setAllAgentsEnabled,
|
||||
resetViewOptions
|
||||
|
|
@ -141,9 +144,10 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
)
|
||||
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(
|
||||
scopePaths,
|
||||
executionHostScope
|
||||
executionHostScope,
|
||||
sessionLimit
|
||||
)
|
||||
// Deliberately blind to the active repo/worktree: rebuilding these ~500-entry
|
||||
// Deliberately blind to the active repo/worktree: rebuilding these session
|
||||
// maps on every worktree switch is what made switching visibly slow (#10841 era).
|
||||
const sessionProjectById = useMemo(
|
||||
() =>
|
||||
|
|
@ -180,7 +184,8 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
agents,
|
||||
sort,
|
||||
group,
|
||||
hideEmptySessions
|
||||
hideEmptySessions,
|
||||
sessionLimit
|
||||
})
|
||||
|
||||
// Workspace is the preferred default, but unavailable context still falls back to All.
|
||||
|
|
@ -316,6 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
sort={sort}
|
||||
group={group}
|
||||
hideEmptySessions={hideEmptySessions}
|
||||
sessionLimit={sessionLimit}
|
||||
adjustmentCount={viewAdjustmentCount}
|
||||
onQueryChange={setQuery}
|
||||
onScopeChange={handleScopeChange}
|
||||
|
|
@ -325,6 +331,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
onSortChange={setSort}
|
||||
onGroupChange={setGroup}
|
||||
onHideEmptySessionsChange={setHideEmptySessions}
|
||||
onSessionLimitChange={setSessionLimit}
|
||||
onReset={resetViewOptions}
|
||||
onRefresh={() => void refresh({ force: true })}
|
||||
/>
|
||||
|
|
@ -335,15 +342,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{scanResult && scanResult.issues.length > 0 ? (
|
||||
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
|
||||
'{{count}} transcript skipped',
|
||||
{ count: scanResult.issues.length }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<AiVaultScanIssueBanners scanResult={scanResult} />
|
||||
|
||||
<AiVaultSessionVirtualList
|
||||
groups={groups}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
Clock3,
|
||||
FolderOpen,
|
||||
ListFilter,
|
||||
LoaderCircle,
|
||||
PanelsTopLeft,
|
||||
Server
|
||||
} from 'lucide-react'
|
||||
|
|
@ -36,6 +35,8 @@ import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shar
|
|||
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
|
||||
import { AiVaultSessionLimitMenu } from './AiVaultSessionLimitMenu'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
|
||||
|
||||
|
|
@ -78,34 +79,6 @@ export function VaultGroupHeader({
|
|||
)
|
||||
}
|
||||
|
||||
export function SessionLoadingState(): React.JSX.Element {
|
||||
return (
|
||||
<div className="px-3 py-3" aria-busy="true">
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions',
|
||||
'Scanning sessions'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="mt-1 size-4 rounded-full bg-sidebar-accent" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="h-3 w-4/5 rounded-sm bg-sidebar-accent" />
|
||||
<div className="h-2.5 w-3/5 rounded-sm bg-sidebar-accent/75" />
|
||||
<div className="h-2.5 w-2/5 rounded-sm bg-sidebar-accent/60" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VaultScopeSwitch({
|
||||
scope,
|
||||
workspaceAvailable,
|
||||
|
|
@ -235,24 +208,28 @@ export function VaultViewMenu({
|
|||
sort,
|
||||
group,
|
||||
hideEmptySessions,
|
||||
sessionLimit,
|
||||
adjustmentCount,
|
||||
onAgentEnabledChange,
|
||||
onAllAgentsEnabledChange,
|
||||
onSortChange,
|
||||
onGroupChange,
|
||||
onHideEmptySessionsChange,
|
||||
onSessionLimitChange,
|
||||
onReset
|
||||
}: {
|
||||
agents: readonly AiVaultAgent[]
|
||||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
adjustmentCount: number
|
||||
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
|
||||
onAllAgentsEnabledChange: (enabled: boolean) => void
|
||||
onSortChange: (sort: AiVaultSort) => void
|
||||
onGroupChange: (group: AiVaultGroup) => void
|
||||
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
|
||||
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
|
||||
onReset: () => void
|
||||
}): React.JSX.Element {
|
||||
const allAgentsSelected = agents.length === AI_VAULT_AGENTS.length
|
||||
|
|
@ -388,6 +365,10 @@ export function VaultViewMenu({
|
|||
'Hide empty sessions'
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<AiVaultSessionLimitMenu
|
||||
sessionLimit={sessionLimit}
|
||||
onSessionLimitChange={onSessionLimitChange}
|
||||
/>
|
||||
{adjustmentCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -403,12 +384,3 @@ export function VaultViewMenu({
|
|||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ title }: { title: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-muted-foreground">
|
||||
<ArchiveRestore className="mb-3 size-7 opacity-50" />
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
|
||||
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
type AiVaultPanelHeaderProps = {
|
||||
query: string
|
||||
|
|
@ -26,6 +27,7 @@ type AiVaultPanelHeaderProps = {
|
|||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
adjustmentCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onScopeChange: (scope: AiVaultScope) => void
|
||||
|
|
@ -35,6 +37,7 @@ type AiVaultPanelHeaderProps = {
|
|||
onSortChange: (sort: AiVaultSort) => void
|
||||
onGroupChange: (group: AiVaultGroup) => void
|
||||
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
|
||||
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
|
||||
onReset: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
|
@ -54,6 +57,7 @@ export function AiVaultPanelHeader({
|
|||
sort,
|
||||
group,
|
||||
hideEmptySessions,
|
||||
sessionLimit,
|
||||
adjustmentCount,
|
||||
onQueryChange,
|
||||
onScopeChange,
|
||||
|
|
@ -63,6 +67,7 @@ export function AiVaultPanelHeader({
|
|||
onSortChange,
|
||||
onGroupChange,
|
||||
onHideEmptySessionsChange,
|
||||
onSessionLimitChange,
|
||||
onReset,
|
||||
onRefresh
|
||||
}: AiVaultPanelHeaderProps): React.JSX.Element {
|
||||
|
|
@ -119,12 +124,14 @@ export function AiVaultPanelHeader({
|
|||
sort={sort}
|
||||
group={group}
|
||||
hideEmptySessions={hideEmptySessions}
|
||||
sessionLimit={sessionLimit}
|
||||
adjustmentCount={adjustmentCount}
|
||||
onAgentEnabledChange={onAgentEnabledChange}
|
||||
onAllAgentsEnabledChange={onAllAgentsEnabledChange}
|
||||
onSortChange={onSortChange}
|
||||
onGroupChange={onGroupChange}
|
||||
onHideEmptySessionsChange={onHideEmptySessionsChange}
|
||||
onSessionLimitChange={onSessionLimitChange}
|
||||
onReset={onReset}
|
||||
/>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type React from 'react'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
aiVaultScanNoticeIssues,
|
||||
blockingAiVaultScanIssue,
|
||||
skippedAiVaultTranscriptCount,
|
||||
skippedAiVaultTranscriptReasons
|
||||
} from './ai-vault-scan-issue-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Messages are scanner-authored (host name, remote path, cap), so they render raw
|
||||
// rather than through a catalog key.
|
||||
export function AiVaultScanIssueBanners({
|
||||
scanResult
|
||||
}: {
|
||||
scanResult: AiVaultListResult | null
|
||||
}): React.JSX.Element {
|
||||
const blocking = blockingAiVaultScanIssue(scanResult)
|
||||
const skippedTranscriptCount = skippedAiVaultTranscriptCount(scanResult)
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocking ? (
|
||||
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
|
||||
{blocking.message}
|
||||
</div>
|
||||
) : null}
|
||||
{aiVaultScanNoticeIssues(scanResult).map((issue) => (
|
||||
<div
|
||||
// Message is part of the key: one host can report several distinct
|
||||
// messages for the same path, and a colliding key drops those rows.
|
||||
key={`${issue.executionHostId ?? 'local'}:${issue.kind}:${issue.agent}:${issue.path}:${issue.message}`}
|
||||
className={`border-b border-sidebar-border px-3 py-1.5 text-[11px] ${
|
||||
issue.kind === 'host' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
{skippedTranscriptCount > 0 ? (
|
||||
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
|
||||
'{{count}} transcript skipped',
|
||||
{ count: skippedTranscriptCount }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{skippedAiVaultTranscriptReasons(scanResult).map((reason) => (
|
||||
<div
|
||||
key={reason}
|
||||
className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{reason}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
AI_VAULT_SESSION_LIMITS,
|
||||
DEFAULT_AI_VAULT_SESSION_LIMIT,
|
||||
type AiVaultSessionLimit
|
||||
} from './ai-vault-session-limit'
|
||||
|
||||
export function AiVaultSessionLimitMenu({
|
||||
sessionLimit,
|
||||
onSessionLimitChange
|
||||
}: {
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
|
||||
}): React.JSX.Element {
|
||||
const sessionLimitLabel =
|
||||
sessionLimit === 'unlimited'
|
||||
? translate('auto.components.right.sidebar.AiVaultSessionLimitMenu.unlimited', 'Unlimited')
|
||||
: sessionLimit.toLocaleString()
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.historyDepth',
|
||||
'History depth: {{value0}}',
|
||||
{ value0: sessionLimitLabel }
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-60">
|
||||
<DropdownMenuLabel className="whitespace-normal font-normal leading-4">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.performanceWarning',
|
||||
'Larger histories can slow the entire app, especially on remote hosts. Unlimited scans all available history.'
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={String(sessionLimit)}
|
||||
onValueChange={(value) =>
|
||||
onSessionLimitChange(
|
||||
value === 'unlimited' ? 'unlimited' : (Number(value) as AiVaultSessionLimit)
|
||||
)
|
||||
}
|
||||
>
|
||||
{AI_VAULT_SESSION_LIMITS.map((limit) => (
|
||||
<DropdownMenuRadioItem key={limit} value={String(limit)}>
|
||||
<span>
|
||||
{limit === 'unlimited'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.unlimited',
|
||||
'Unlimited'
|
||||
)
|
||||
: limit.toLocaleString()}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
|
||||
{limit === DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.recommended',
|
||||
'Recommended'
|
||||
)
|
||||
: limit === 500
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.mayBeSlower',
|
||||
'May be slower'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionLimitMenu.slowest',
|
||||
'Slowest'
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { ArchiveRestore, LoaderCircle } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function SessionLoadingState(): React.JSX.Element {
|
||||
return (
|
||||
<div className="px-3 py-3" aria-busy="true">
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions',
|
||||
'Scanning sessions'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="mt-1 size-4 rounded-full bg-sidebar-accent" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="h-3 w-4/5 rounded-sm bg-sidebar-accent" />
|
||||
<div className="h-2.5 w-3/5 rounded-sm bg-sidebar-accent/75" />
|
||||
<div className="h-2.5 w-2/5 rounded-sm bg-sidebar-accent/60" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ title }: { title: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-muted-foreground">
|
||||
<ArchiveRestore className="mb-3 size-7 opacity-50" />
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-virtual-rows'
|
||||
import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls'
|
||||
import { VaultGroupHeader } from './AiVaultPanelControls'
|
||||
import { EmptyState, SessionLoadingState } from './AiVaultSessionListStates'
|
||||
import { VaultSessionRow } from './AiVaultSessionRow'
|
||||
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
|
||||
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
aiVaultScanNoticeIssues,
|
||||
blockingAiVaultScanIssue,
|
||||
skippedAiVaultTranscriptCount,
|
||||
skippedAiVaultTranscriptReasons
|
||||
} from './ai-vault-scan-issue-state'
|
||||
|
||||
describe('blockingAiVaultScanIssue', () => {
|
||||
it('surfaces the cause when a scan returns no sessions', () => {
|
||||
const issue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped. Reconnect the SSH target.'
|
||||
}
|
||||
|
||||
expect(blockingAiVaultScanIssue(result([], [issue]))).toEqual(issue)
|
||||
})
|
||||
|
||||
it('leaves partial-result issues as a skipped transcript count', () => {
|
||||
expect(
|
||||
blockingAiVaultScanIssue(
|
||||
result(
|
||||
[{ id: 'session' }],
|
||||
[{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }]
|
||||
)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not block an empty scan for a skipped transcript', () => {
|
||||
expect(
|
||||
blockingAiVaultScanIssue(
|
||||
result([], [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }])
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultScanNoticeIssues', () => {
|
||||
it('surfaces scope truncation without counting it as a skipped transcript', () => {
|
||||
const scopeIssue = {
|
||||
agent: 'codex' as const,
|
||||
kind: 'scope' as const,
|
||||
path: '/home/ada',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
}
|
||||
const truncated = result([], [scopeIssue])
|
||||
|
||||
expect(blockingAiVaultScanIssue(truncated)).toBeNull()
|
||||
expect(aiVaultScanNoticeIssues(truncated)).toEqual([scopeIssue])
|
||||
expect(skippedAiVaultTranscriptCount(truncated)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps kinded issues as notices and counts only transcripts as skipped', () => {
|
||||
const hostIssue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped.'
|
||||
}
|
||||
const scopeIssue = {
|
||||
agent: 'codex' as const,
|
||||
kind: 'scope' as const,
|
||||
path: '/home/ada',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
}
|
||||
const partial = result(
|
||||
[{ id: 'session' }],
|
||||
[hostIssue, scopeIssue, { agent: 'codex', path: '/bad.jsonl', message: 'Malformed' }]
|
||||
)
|
||||
|
||||
expect(aiVaultScanNoticeIssues(partial)).toEqual([hostIssue, scopeIssue])
|
||||
expect(skippedAiVaultTranscriptCount(partial)).toBe(1)
|
||||
})
|
||||
|
||||
it('does not repeat the blocking issue as a notice row', () => {
|
||||
const hostIssue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped.'
|
||||
}
|
||||
|
||||
expect(aiVaultScanNoticeIssues(result([], [hostIssue]))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports nothing before the first scan', () => {
|
||||
expect(aiVaultScanNoticeIssues(null)).toEqual([])
|
||||
expect(skippedAiVaultTranscriptCount(null)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skippedAiVaultTranscriptReasons', () => {
|
||||
it('surfaces the file-too-large reason behind a skipped transcript', () => {
|
||||
expect(
|
||||
skippedAiVaultTranscriptReasons(
|
||||
result(
|
||||
[{ id: 'session' }],
|
||||
[
|
||||
{
|
||||
agent: 'claude',
|
||||
path: '/home/dev/.claude/projects/a/huge.jsonl',
|
||||
message: 'File too large: 12.4MB exceeds 10MB limit'
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
).toEqual(['File too large: 12.4MB exceeds 10MB limit'])
|
||||
})
|
||||
|
||||
it('leaves host and scope notices to their own rows', () => {
|
||||
expect(
|
||||
skippedAiVaultTranscriptReasons(
|
||||
result(
|
||||
[],
|
||||
[
|
||||
{
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: '/home/dev',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
},
|
||||
{ agent: 'codex', kind: 'host', path: 'dev-box', message: 'Reconnect the SSH target.' }
|
||||
]
|
||||
)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes repeats and caps the list so a 500-issue scan stays readable', () => {
|
||||
const issues = Array.from({ length: 40 }, (_unused, index) => ({
|
||||
agent: 'codex' as const,
|
||||
path: `/transcripts/${index}.jsonl`,
|
||||
message: `Unreadable transcript ${index % 5}`
|
||||
}))
|
||||
|
||||
expect(skippedAiVaultTranscriptReasons(result([], issues))).toEqual([
|
||||
'Unreadable transcript 0',
|
||||
'Unreadable transcript 1',
|
||||
'Unreadable transcript 2'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports nothing before the first scan', () => {
|
||||
expect(skippedAiVaultTranscriptReasons(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function result(
|
||||
sessions: { id: string }[],
|
||||
issues: AiVaultListResult['issues']
|
||||
): AiVaultListResult {
|
||||
return {
|
||||
sessions: sessions as AiVaultListResult['sessions'],
|
||||
issues,
|
||||
scannedAt: '2026-07-26T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { AiVaultListResult, AiVaultScanIssue } from '../../../../shared/ai-vault-types'
|
||||
|
||||
export function blockingAiVaultScanIssue(
|
||||
result: AiVaultListResult | null
|
||||
): AiVaultScanIssue | null {
|
||||
if (!result || result.sessions.length > 0) {
|
||||
return null
|
||||
}
|
||||
return result.issues.find((issue) => issue.kind === 'host') ?? null
|
||||
}
|
||||
|
||||
// Host and scope issues carry their own scanner-authored copy, so they get their
|
||||
// own rows instead of being counted as skipped transcripts — a partial scan
|
||||
// (one SSH host down, rest fine) must not report a connectivity failure as a
|
||||
// skipped transcript file.
|
||||
export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] {
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
const blocking = blockingAiVaultScanIssue(result)
|
||||
return result.issues.filter((issue) => Boolean(issue.kind) && issue !== blocking)
|
||||
}
|
||||
|
||||
export function skippedAiVaultTranscriptCount(result: AiVaultListResult | null): number {
|
||||
return result ? result.issues.filter((issue) => !issue.kind).length : 0
|
||||
}
|
||||
|
||||
const SKIPPED_TRANSCRIPT_REASON_LIMIT = 3
|
||||
|
||||
// Why: a bare "3 transcripts skipped" hides the actionable part (a 10 MiB cap
|
||||
// hit, an unreadable transcript). Surface the distinct scanner-authored reasons,
|
||||
// capped so a 500-issue scan can't turn the panel into a wall of text.
|
||||
export function skippedAiVaultTranscriptReasons(result: AiVaultListResult | null): string[] {
|
||||
const reasons = new Set<string>()
|
||||
for (const issue of result?.issues ?? []) {
|
||||
if (issue.kind) {
|
||||
continue
|
||||
}
|
||||
const message = issue.message.trim()
|
||||
if (message) {
|
||||
reasons.add(message)
|
||||
}
|
||||
if (reasons.size === SKIPPED_TRANSCRIPT_REASON_LIMIT) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...reasons]
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export const AI_VAULT_SESSION_LIMITS = [250, 500, 1000, 'unlimited'] as const
|
||||
|
||||
export type AiVaultSessionLimit = (typeof AI_VAULT_SESSION_LIMITS)[number]
|
||||
|
||||
export const DEFAULT_AI_VAULT_SESSION_LIMIT: AiVaultSessionLimit = 250
|
||||
|
||||
export function normalizeAiVaultSessionLimit(value: unknown): AiVaultSessionLimit {
|
||||
return AI_VAULT_SESSION_LIMITS.includes(value as AiVaultSessionLimit)
|
||||
? (value as AiVaultSessionLimit)
|
||||
: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
}
|
||||
|
|
@ -4,12 +4,15 @@ import { act, createElement } from 'react'
|
|||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
isAiVaultScanCancellation,
|
||||
resetAiVaultForcedRescanThrottleForTest,
|
||||
useAiVaultSessionRefresh
|
||||
} from './ai-vault-session-refresh'
|
||||
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
const EMPTY_RESULT: AiVaultListResult = {
|
||||
sessions: [],
|
||||
|
|
@ -20,6 +23,7 @@ const EMPTY_RESULT: AiVaultListResult = {
|
|||
const THROTTLE_MS = 5_000
|
||||
|
||||
const listSessionsMock = vi.fn<(args: unknown) => Promise<AiVaultListResult>>()
|
||||
const cancelListSessionsMock = vi.fn<() => Promise<void>>()
|
||||
|
||||
// Captures the hook's subscription to the main-process window-focus push.
|
||||
let windowFocusCallback: (() => void) | null = null
|
||||
|
|
@ -39,40 +43,70 @@ async function fireWindowFocused(): Promise<void> {
|
|||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
|
||||
describe('isAiVaultScanCancellation', () => {
|
||||
it('recognises a cancellation through the IPC error wrapper', () => {
|
||||
expect(
|
||||
isAiVaultScanCancellation(
|
||||
new Error(
|
||||
"Error invoking remote method 'aiVault:listSessions': Error: Agent Session History scan was cancelled"
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recognises an in-process AbortError', () => {
|
||||
const error = new Error('aborted')
|
||||
error.name = 'AbortError'
|
||||
expect(isAiVaultScanCancellation(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a real scan failure reportable', () => {
|
||||
expect(isAiVaultScanCancellation(new Error('SSH relay is not ready'))).toBe(false)
|
||||
expect(isAiVaultScanCancellation('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const roots: Root[] = []
|
||||
let latest: ReturnType<typeof useAiVaultSessionRefresh> | null = null
|
||||
|
||||
function HookProbe(props: {
|
||||
scopePaths: readonly string[]
|
||||
executionHostScope?: 'local' | 'all' | `ssh:${string}`
|
||||
executionHostScope?: ExecutionHostScope
|
||||
sessionLimit?: AiVaultSessionLimit
|
||||
}): null {
|
||||
latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local')
|
||||
latest = useAiVaultSessionRefresh(
|
||||
props.scopePaths,
|
||||
props.executionHostScope ?? 'local',
|
||||
props.sessionLimit ?? DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderHook(
|
||||
scopePaths: readonly string[] = [],
|
||||
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
|
||||
executionHostScope: ExecutionHostScope = 'local',
|
||||
sessionLimit: AiVaultSessionLimit = DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope, sessionLimit }))
|
||||
})
|
||||
}
|
||||
|
||||
async function rerenderHook(
|
||||
scopePaths: readonly string[] = [],
|
||||
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
|
||||
executionHostScope: ExecutionHostScope = 'local',
|
||||
sessionLimit: AiVaultSessionLimit = DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
): Promise<void> {
|
||||
const root = roots.at(-1)
|
||||
if (!root) {
|
||||
throw new Error('renderHook must be called before rerenderHook')
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope, sessionLimit }))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +143,33 @@ function makeAgentEntry(sessionId: string, state = 'working'): AgentStatusEntry
|
|||
} as AgentStatusEntry
|
||||
}
|
||||
|
||||
function makeVaultSession(index: number): AiVaultSession {
|
||||
const id = `session-${index}`
|
||||
const timestamp = new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString()
|
||||
return {
|
||||
id,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
agent: 'codex',
|
||||
sessionId: id,
|
||||
title: id,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${id}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
modifiedAt: timestamp,
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: id,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
async function setAgentStatuses(entries: Record<string, AgentStatusEntry>): Promise<void> {
|
||||
await act(async () => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: entries })
|
||||
|
|
@ -123,9 +184,14 @@ function lastCallArgs(): unknown {
|
|||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
listSessionsMock.mockReset().mockResolvedValue(EMPTY_RESULT)
|
||||
cancelListSessionsMock.mockReset().mockResolvedValue()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim
|
||||
;(window as any).api = {
|
||||
aiVault: { listSessions: listSessionsMock, onWindowFocused: onWindowFocusedMock }
|
||||
aiVault: {
|
||||
listSessions: listSessionsMock,
|
||||
cancelListSessions: cancelListSessionsMock,
|
||||
onWindowFocused: onWindowFocusedMock
|
||||
}
|
||||
}
|
||||
resetAiVaultForcedRescanThrottleForTest()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
|
|
@ -151,6 +217,24 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.each(['ssh:dev-box', 'runtime:remote-server'] as const)(
|
||||
'uses the cache on %s panel entry',
|
||||
async (executionHostScope) => {
|
||||
await renderHook(['/repo'], executionHostScope)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(lastCallArgs()).toMatchObject({
|
||||
executionHostScope,
|
||||
scopePaths: ['/repo'],
|
||||
force: false
|
||||
})
|
||||
|
||||
await advance(THROTTLE_MS + 1)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes the requested execution host scope to the scanner', async () => {
|
||||
await renderHook(['/repo'], 'ssh:dev-box')
|
||||
await flushMicrotasks()
|
||||
|
|
@ -162,6 +246,73 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('re-scans with the selected history depth', async () => {
|
||||
await renderHook(['/repo'], 'ssh:dev-box')
|
||||
await flushMicrotasks()
|
||||
|
||||
await rerenderHook(['/repo'], 'ssh:dev-box', 1000)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(lastCallArgs()).toMatchObject({ limit: 1000, force: false })
|
||||
})
|
||||
|
||||
it('reuses a loaded larger depth when lowering and raising within its coverage', async () => {
|
||||
const loaded = {
|
||||
...EMPTY_RESULT,
|
||||
sessions: Array.from({ length: 600 }, (_, index) => makeVaultSession(index))
|
||||
}
|
||||
listSessionsMock.mockResolvedValueOnce(loaded)
|
||||
await renderHook([], 'ssh:dev-box', 1000)
|
||||
await flushMicrotasks()
|
||||
|
||||
await rerenderHook([], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
expect(latest?.sessions).toHaveLength(250)
|
||||
|
||||
await rerenderHook([], 'ssh:dev-box', 500)
|
||||
await flushMicrotasks()
|
||||
expect(latest?.sessions).toHaveLength(500)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the rendered result after the panel remounts on a tab switch', async () => {
|
||||
const loaded = { ...EMPTY_RESULT, sessions: [makeVaultSession(1)] }
|
||||
listSessionsMock.mockResolvedValueOnce(loaded)
|
||||
await renderHook(['/repo'], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
|
||||
roots.splice(0).forEach((root) => act(() => root.unmount()))
|
||||
await renderHook(['/repo'], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(latest?.sessions).toEqual(loaded.sessions)
|
||||
})
|
||||
|
||||
it('reuses each workspace result when switching back across tabs', async () => {
|
||||
listSessionsMock
|
||||
.mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(1)] })
|
||||
.mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(2)] })
|
||||
await renderHook(['/repo-a'], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
|
||||
await rerenderHook(['/repo-b'], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
await rerenderHook(['/repo-a'], 'ssh:dev-box', 250)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(latest?.sessions[0]?.id).toBe('session-1')
|
||||
})
|
||||
|
||||
it('requests an uncapped scan for Unlimited', async () => {
|
||||
await renderHook([], 'ssh:dev-box', 'unlimited')
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(lastCallArgs()).toMatchObject({ limit: undefined, unlimited: true, force: false })
|
||||
})
|
||||
|
||||
it('does not apply stale results after the host scope changes mid-scan', async () => {
|
||||
let resolveLocal: ((result: AiVaultListResult) => void) | null = null
|
||||
let resolveSsh: ((result: AiVaultListResult) => void) | null = null
|
||||
|
|
@ -178,6 +329,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
|||
|
||||
await rerenderHook(['/remote/repo'], 'ssh:dev-box')
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(cancelListSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' })
|
||||
|
|
@ -254,6 +406,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
|||
await dispatch(document, 'visibilitychange')
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(cancelListSessionsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not raise the loading flag for refocus refreshes', async () => {
|
||||
|
|
@ -297,6 +450,24 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
|||
expect(latest?.scanResult).not.toBe(firstResult)
|
||||
})
|
||||
|
||||
it('keeps the current list when a superseded scan resolves cancelled', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
const applied = latest?.scanResult
|
||||
|
||||
listSessionsMock.mockResolvedValueOnce({
|
||||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-01T00:00:09.000Z',
|
||||
cancelled: true
|
||||
})
|
||||
await advance(THROTTLE_MS + 1)
|
||||
await fireWindowFocused()
|
||||
|
||||
expect(latest?.scanResult).toBe(applied)
|
||||
expect(latest?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the manual refresh button forcing a cache bypass', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
isAiVaultScanCancelledError,
|
||||
type AiVaultListResult,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const SESSION_LIMIT = 500
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
import {
|
||||
aiVaultSessionResultCacheKey,
|
||||
cacheAiVaultSessionResult,
|
||||
readCachedAiVaultSessionResult,
|
||||
resetAiVaultSessionResultCacheForTest
|
||||
} from './ai-vault-session-result-cache'
|
||||
|
||||
// Panel entry and window refocus must show sessions started since the last
|
||||
// scan, so they bypass the main process's 15s cache — but a full scan parses
|
||||
|
|
@ -23,13 +32,19 @@ function consumeForcedRescanBudget(): boolean {
|
|||
|
||||
export function resetAiVaultForcedRescanThrottleForTest(): void {
|
||||
lastForcedRescanAt = 0
|
||||
resetAiVaultSessionResultCacheForTest()
|
||||
}
|
||||
|
||||
type AiVaultRefreshArgs = { force?: boolean; background?: boolean }
|
||||
// Desktop IPC reports cancellation as a result, but the web/runtime RPC path
|
||||
// still rejects, so both shapes have to be recognised.
|
||||
export const isAiVaultScanCancellation = isAiVaultScanCancelledError
|
||||
|
||||
type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean }
|
||||
|
||||
export function useAiVaultSessionRefresh(
|
||||
scopePaths: readonly string[],
|
||||
executionHostScope: ExecutionHostScope
|
||||
executionHostScope: ExecutionHostScope,
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
): {
|
||||
error: string | null
|
||||
loading: boolean
|
||||
|
|
@ -41,6 +56,7 @@ export function useAiVaultSessionRefresh(
|
|||
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const requestTokenRef = useRef(crypto.randomUUID())
|
||||
const refreshIdRef = useRef(0)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const pendingRefreshRef = useRef(false)
|
||||
|
|
@ -48,19 +64,47 @@ export function useAiVaultSessionRefresh(
|
|||
const pendingBackgroundRef = useRef(true)
|
||||
const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
|
||||
const scanScopeKey = `${executionHostScope}\n${scopePathsKey}`
|
||||
const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}`
|
||||
const scopePathsRef = useRef<readonly string[]>(scopePaths)
|
||||
scopePathsRef.current = scopePaths
|
||||
const executionHostScopeRef = useRef<ExecutionHostScope>(executionHostScope)
|
||||
executionHostScopeRef.current = executionHostScope
|
||||
const sessionLimitRef = useRef(sessionLimit)
|
||||
// Keep render pure for React Doctor; layout effect still lands before refresh effects.
|
||||
useLayoutEffect(() => {
|
||||
sessionLimitRef.current = sessionLimit
|
||||
}, [sessionLimit])
|
||||
const currentScanScopeKey = useCallback(
|
||||
() => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`,
|
||||
() =>
|
||||
`${aiVaultSessionResultCacheKey(
|
||||
executionHostScopeRef.current,
|
||||
scopePathsRef.current
|
||||
)}\n${sessionLimitRef.current}`,
|
||||
[]
|
||||
)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (args: AiVaultRefreshArgs = {}): Promise<void> => {
|
||||
const hostScope = executionHostScopeRef.current
|
||||
const selectedLimit = sessionLimitRef.current
|
||||
const baseKey = aiVaultSessionResultCacheKey(hostScope, scopePathsRef.current)
|
||||
const cachedResult =
|
||||
args.reuseLoadedDepth === true
|
||||
? readCachedAiVaultSessionResult({
|
||||
key: baseKey,
|
||||
limit: selectedLimit,
|
||||
scopePaths: scopePathsRef.current
|
||||
})
|
||||
: null
|
||||
if (cachedResult) {
|
||||
const scanKey = `${baseKey}\n${selectedLimit}`
|
||||
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
|
||||
setError(null)
|
||||
setScanResult(cachedResult)
|
||||
setSessions(cachedResult.sessions)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// A scope change during an in-flight scan must not be dropped; queue one more
|
||||
// scan so the current scoped view is refreshed after the older scan settles.
|
||||
if (refreshInFlightRef.current) {
|
||||
|
|
@ -85,17 +129,20 @@ export function useAiVaultSessionRefresh(
|
|||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
const scopeKey = scopePathsRef.current.join('\n')
|
||||
const hostScope = executionHostScopeRef.current
|
||||
const scanKey = `${hostScope}\n${scopeKey}`
|
||||
const limit = selectedLimit === 'unlimited' ? undefined : selectedLimit
|
||||
const scanKey = `${baseKey}\n${selectedLimit}`
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
limit: SESSION_LIMIT,
|
||||
limit,
|
||||
unlimited: selectedLimit === 'unlimited',
|
||||
scopePaths: scopePathsRef.current,
|
||||
executionHostScope: hostScope,
|
||||
force: args.force
|
||||
force: args.force,
|
||||
requestToken: requestTokenRef.current
|
||||
})
|
||||
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
// A superseded scan resolves cancelled rather than rejecting, so the
|
||||
// main-process log stays clean; its empty body must not be painted.
|
||||
if (result.cancelled || !mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
return
|
||||
}
|
||||
// Why: host/scope changes queue a follow-up scan, but the older result
|
||||
|
|
@ -112,10 +159,21 @@ export function useAiVaultSessionRefresh(
|
|||
return
|
||||
}
|
||||
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt }
|
||||
cacheAiVaultSessionResult({
|
||||
key: baseKey,
|
||||
executionHostScope: hostScope,
|
||||
limit: selectedLimit,
|
||||
result,
|
||||
replaceHostEntries: args.force === true
|
||||
})
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
} catch (err) {
|
||||
// A cancelled scan is not a failure: another caller's forced refresh
|
||||
// preempts the shared scan, and painting its abort would replace the
|
||||
// list with an error the incoming scan is about to make obsolete.
|
||||
if (
|
||||
!isAiVaultScanCancellation(err) &&
|
||||
mountedRef.current &&
|
||||
refreshIdRef.current === refreshId &&
|
||||
scanKey === currentScanScopeKey()
|
||||
|
|
@ -167,10 +225,14 @@ export function useAiVaultSessionRefresh(
|
|||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const requestToken = requestTokenRef.current
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
refreshIdRef.current += 1
|
||||
refreshInFlightRef.current = false
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
requestToken
|
||||
})
|
||||
if (forcedRescanTimerRef.current !== null) {
|
||||
clearTimeout(forcedRescanTimerRef.current)
|
||||
forcedRescanTimerRef.current = null
|
||||
|
|
@ -178,17 +240,22 @@ export function useAiVaultSessionRefresh(
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Re-scan on mount and whenever the active scope changes, since the scanner
|
||||
// tailors its in-scope results to scopePaths. Force (throttled) so
|
||||
// re-entering the panel shows sessions newer than the 15s cache; when the
|
||||
// throttle denies it, paint from cache now and catch up once it frees.
|
||||
// Remote scans can take long enough for normal tab navigation to feel stuck,
|
||||
// so re-entering a remote panel uses its host/scope cache. Explicit refresh,
|
||||
// app refocus and new in-app agent sessions still force a fresh scan.
|
||||
useEffect(() => {
|
||||
const force = consumeForcedRescanBudget()
|
||||
void refresh({ force })
|
||||
if (!force) {
|
||||
if (refreshInFlightRef.current) {
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
requestToken: requestTokenRef.current
|
||||
})
|
||||
}
|
||||
const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID
|
||||
const force = refreshOnEntry && consumeForcedRescanBudget()
|
||||
void refresh({ force, reuseLoadedDepth: true })
|
||||
if (refreshOnEntry && !force) {
|
||||
requestForcedRescan()
|
||||
}
|
||||
}, [refresh, requestForcedRescan, scanScopeKey])
|
||||
}, [executionHostScope, refresh, requestForcedRescan, scanScopeKey])
|
||||
|
||||
// Sessions started while the app was backgrounded should appear when the
|
||||
// user returns, so refocus also bypasses the scan cache (throttled). OS
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import {
|
||||
aiVaultSessionDepthCovers,
|
||||
truncateAiVaultListResult
|
||||
} from '../../../../shared/ai-vault-session-depth'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
const MAX_CACHED_SESSION_SCOPES = 8
|
||||
|
||||
type CachedSessionResult = {
|
||||
executionHostScope: ExecutionHostScope
|
||||
limit: AiVaultSessionLimit
|
||||
result: AiVaultListResult
|
||||
}
|
||||
|
||||
const cachedSessionResults = new Map<string, CachedSessionResult>()
|
||||
|
||||
export function aiVaultSessionResultCacheKey(
|
||||
executionHostScope: ExecutionHostScope,
|
||||
scopePaths: readonly string[]
|
||||
): string {
|
||||
// JSON keeps the parts unambiguous: a path may legally contain any separator.
|
||||
return JSON.stringify([executionHostScope, ...[...new Set(scopePaths)].sort()])
|
||||
}
|
||||
|
||||
export function readCachedAiVaultSessionResult(args: {
|
||||
key: string
|
||||
limit: AiVaultSessionLimit
|
||||
scopePaths: readonly string[]
|
||||
}): AiVaultListResult | null {
|
||||
const cached = cachedSessionResults.get(args.key)
|
||||
if (!cached || !aiVaultSessionDepthCovers(cached.limit, args.limit)) {
|
||||
return null
|
||||
}
|
||||
cachedSessionResults.delete(args.key)
|
||||
cachedSessionResults.set(args.key, cached)
|
||||
return truncateAiVaultListResult(cached.result, args.limit, args.scopePaths)
|
||||
}
|
||||
|
||||
export function cacheAiVaultSessionResult(args: {
|
||||
key: string
|
||||
executionHostScope: ExecutionHostScope
|
||||
limit: AiVaultSessionLimit
|
||||
result: AiVaultListResult
|
||||
replaceHostEntries: boolean
|
||||
}): void {
|
||||
if (args.replaceHostEntries) {
|
||||
for (const [key, cached] of cachedSessionResults) {
|
||||
if (cached.executionHostScope === args.executionHostScope) {
|
||||
cachedSessionResults.delete(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const cached = cachedSessionResults.get(args.key)
|
||||
if (cached && aiVaultSessionDepthCovers(cached.limit, args.limit)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
cachedSessionResults.delete(args.key)
|
||||
cachedSessionResults.set(args.key, {
|
||||
executionHostScope: args.executionHostScope,
|
||||
limit: args.limit,
|
||||
result: args.result
|
||||
})
|
||||
while (cachedSessionResults.size > MAX_CACHED_SESSION_SCOPES) {
|
||||
const oldestKey = cachedSessionResults.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
cachedSessionResults.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAiVaultSessionResultCacheForTest(): void {
|
||||
cachedSessionResults.clear()
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
DEFAULT_AI_VAULT_SORT
|
||||
} from './ai-vault-view-defaults'
|
||||
import { DEFAULT_AI_VAULT_SESSION_LIMIT } from './ai-vault-session-limit'
|
||||
|
||||
describe('ai-vault-view-defaults', () => {
|
||||
it('shows empty sessions by default', () => {
|
||||
|
|
@ -18,7 +19,8 @@ describe('ai-vault-view-defaults', () => {
|
|||
agents: [...AI_VAULT_AGENTS],
|
||||
sort: DEFAULT_AI_VAULT_SORT,
|
||||
group: DEFAULT_AI_VAULT_GROUP,
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
})
|
||||
).toBe(0)
|
||||
})
|
||||
|
|
@ -29,7 +31,8 @@ describe('ai-vault-view-defaults', () => {
|
|||
agents: [...AI_VAULT_AGENTS],
|
||||
sort: DEFAULT_AI_VAULT_SORT,
|
||||
group: DEFAULT_AI_VAULT_GROUP,
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
})
|
||||
).toBe(1)
|
||||
})
|
||||
|
|
@ -40,7 +43,8 @@ describe('ai-vault-view-defaults', () => {
|
|||
agents: [...AI_VAULT_AGENTS],
|
||||
sort: DEFAULT_AI_VAULT_SORT,
|
||||
group: DEFAULT_AI_VAULT_GROUP,
|
||||
hideEmptySessions: false
|
||||
hideEmptySessions: false,
|
||||
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
})
|
||||
).toBe(0)
|
||||
})
|
||||
|
|
@ -54,7 +58,8 @@ describe('ai-vault-view-defaults', () => {
|
|||
agents: swapped,
|
||||
sort: DEFAULT_AI_VAULT_SORT,
|
||||
group: DEFAULT_AI_VAULT_GROUP,
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
})
|
||||
).toBe(1)
|
||||
})
|
||||
|
|
@ -65,8 +70,9 @@ describe('ai-vault-view-defaults', () => {
|
|||
agents: ['claude'],
|
||||
sort: 'created',
|
||||
group: 'agent',
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: 1000
|
||||
})
|
||||
).toBe(4)
|
||||
).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
type AiVaultGroup,
|
||||
type AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
// Why: hide-empty used to default true; keep initial state, badge count, and Reset view
|
||||
// on one constant so a default flip cannot leave Reset pointing at the old value.
|
||||
|
|
@ -16,6 +17,7 @@ export function countAiVaultViewAdjustments(options: {
|
|||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
}): number {
|
||||
// Why: count by membership, not length — an agent swap keeps the array length but
|
||||
// still deviates from the default of every agent enabled.
|
||||
|
|
@ -24,6 +26,7 @@ export function countAiVaultViewAdjustments(options: {
|
|||
(allAgentsEnabled ? 0 : 1) +
|
||||
(options.sort === DEFAULT_AI_VAULT_SORT ? 0 : 1) +
|
||||
(options.group === DEFAULT_AI_VAULT_GROUP ? 0 : 1) +
|
||||
(options.hideEmptySessions === DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS ? 0 : 1)
|
||||
(options.hideEmptySessions === DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS ? 0 : 1) +
|
||||
(options.sessionLimit === DEFAULT_AI_VAULT_SESSION_LIMIT ? 0 : 1)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ describe('AI Vault view option persistence', () => {
|
|||
disabledAgents: ['codex', 'unknown', 'codex', 7],
|
||||
sort: 'invalid',
|
||||
group: 'agent',
|
||||
hideEmptySessions: 'yes'
|
||||
hideEmptySessions: 'yes',
|
||||
sessionLimit: 999
|
||||
})
|
||||
).toEqual({
|
||||
disabledAgents: ['codex'],
|
||||
sort: 'updated',
|
||||
group: 'agent',
|
||||
hideEmptySessions: false
|
||||
hideEmptySessions: false,
|
||||
sessionLimit: 250
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -42,28 +44,35 @@ describe('AI Vault view option persistence', () => {
|
|||
disabledAgents: [],
|
||||
sort: 'updated',
|
||||
group: 'project',
|
||||
hideEmptySessions: false
|
||||
hideEmptySessions: false,
|
||||
sessionLimit: 250
|
||||
})
|
||||
).toEqual({
|
||||
disabledAgents: [],
|
||||
sort: 'updated',
|
||||
group: 'project',
|
||||
hideEmptySessions: false
|
||||
hideEmptySessions: false,
|
||||
sessionLimit: 250
|
||||
})
|
||||
expect(
|
||||
normalizeAiVaultViewOptions({
|
||||
disabledAgents: [],
|
||||
sort: 'created',
|
||||
group: 'folder',
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: 1000
|
||||
})
|
||||
).toEqual({
|
||||
disabledAgents: [],
|
||||
sort: 'created',
|
||||
group: 'folder',
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: 1000
|
||||
})
|
||||
expect(normalizeAiVaultViewOptions({ group: 'agent' }).group).toBe('agent')
|
||||
expect(normalizeAiVaultViewOptions({ sessionLimit: 'unlimited' }).sessionLimit).toBe(
|
||||
'unlimited'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a fully cleared agent selection', () => {
|
||||
|
|
@ -114,7 +123,8 @@ describe('AI Vault view option persistence', () => {
|
|||
disabledAgents: ['codex'],
|
||||
sort: 'created',
|
||||
group: 'folder',
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: 500
|
||||
},
|
||||
storage
|
||||
)
|
||||
|
|
@ -125,7 +135,8 @@ describe('AI Vault view option persistence', () => {
|
|||
disabledAgents: ['codex'],
|
||||
sort: 'created',
|
||||
group: 'folder',
|
||||
hideEmptySessions: true
|
||||
hideEmptySessions: true,
|
||||
sessionLimit: 500
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
DEFAULT_AI_VAULT_SORT
|
||||
} from './ai-vault-view-defaults'
|
||||
import {
|
||||
DEFAULT_AI_VAULT_SESSION_LIMIT,
|
||||
normalizeAiVaultSessionLimit,
|
||||
type AiVaultSessionLimit
|
||||
} from './ai-vault-session-limit'
|
||||
|
||||
export const AI_VAULT_VIEW_OPTIONS_STORAGE_KEY = 'orca.aiVault.viewOptions.v1'
|
||||
|
||||
|
|
@ -17,6 +22,7 @@ export type AiVaultViewOptions = {
|
|||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
}
|
||||
|
||||
type AiVaultViewOptionsStorage = {
|
||||
|
|
@ -29,7 +35,8 @@ export function createDefaultAiVaultViewOptions(): AiVaultViewOptions {
|
|||
disabledAgents: [],
|
||||
sort: DEFAULT_AI_VAULT_SORT,
|
||||
group: DEFAULT_AI_VAULT_GROUP,
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
|
||||
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +70,8 @@ export function normalizeAiVaultViewOptions(value: unknown): AiVaultViewOptions
|
|||
hideEmptySessions:
|
||||
typeof record.hideEmptySessions === 'boolean'
|
||||
? record.hideEmptySessions
|
||||
: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
|
||||
: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
|
||||
sessionLimit: normalizeAiVaultSessionLimit(record.sessionLimit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ describe('usePersistedAiVaultViewOptions', () => {
|
|||
first.result.current.setSort('created')
|
||||
first.result.current.setGroup('folder')
|
||||
first.result.current.setHideEmptySessions(true)
|
||||
first.result.current.setSessionLimit('unlimited')
|
||||
})
|
||||
first.unmount()
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ describe('usePersistedAiVaultViewOptions', () => {
|
|||
expect(restored.result.current.sort).toBe('created')
|
||||
expect(restored.result.current.group).toBe('folder')
|
||||
expect(restored.result.current.hideEmptySessions).toBe(true)
|
||||
expect(restored.result.current.sessionLimit).toBe('unlimited')
|
||||
})
|
||||
|
||||
it('allows clearing every agent so a single agent can be re-enabled', () => {
|
||||
|
|
@ -81,6 +83,7 @@ describe('usePersistedAiVaultViewOptions', () => {
|
|||
hook.result.current.setSort('created')
|
||||
hook.result.current.setGroup('agent')
|
||||
hook.result.current.setHideEmptySessions(true)
|
||||
hook.result.current.setSessionLimit(1000)
|
||||
hook.result.current.resetViewOptions()
|
||||
})
|
||||
|
||||
|
|
@ -88,6 +91,7 @@ describe('usePersistedAiVaultViewOptions', () => {
|
|||
expect(hook.result.current.sort).toBe('updated')
|
||||
expect(hook.result.current.group).toBe('project')
|
||||
expect(hook.result.current.hideEmptySessions).toBe(false)
|
||||
expect(hook.result.current.sessionLimit).toBe(250)
|
||||
|
||||
hook.unmount()
|
||||
const restored = renderHook(() => usePersistedAiVaultViewOptions())
|
||||
|
|
@ -95,5 +99,6 @@ describe('usePersistedAiVaultViewOptions', () => {
|
|||
expect(restored.result.current.sort).toBe('updated')
|
||||
expect(restored.result.current.group).toBe('project')
|
||||
expect(restored.result.current.hideEmptySessions).toBe(false)
|
||||
expect(restored.result.current.sessionLimit).toBe(250)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
writeAiVaultViewOptions,
|
||||
type AiVaultViewOptions
|
||||
} from './ai-vault-view-options-persistence'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
|
||||
type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOptions
|
||||
|
||||
|
|
@ -20,9 +21,11 @@ export function usePersistedAiVaultViewOptions(): {
|
|||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
setSort: (sort: AiVaultSort) => void
|
||||
setGroup: (group: AiVaultGroup) => void
|
||||
setHideEmptySessions: (hide: boolean) => void
|
||||
setSessionLimit: (limit: AiVaultSessionLimit) => void
|
||||
setAgentEnabled: (agent: AiVaultAgent, enabled: boolean) => void
|
||||
setAllAgentsEnabled: (enabled: boolean) => void
|
||||
resetViewOptions: () => void
|
||||
|
|
@ -65,6 +68,13 @@ export function usePersistedAiVaultViewOptions(): {
|
|||
),
|
||||
[updateOptions]
|
||||
)
|
||||
const setSessionLimit = useCallback(
|
||||
(sessionLimit: AiVaultSessionLimit) =>
|
||||
updateOptions((current) =>
|
||||
current.sessionLimit === sessionLimit ? current : { ...current, sessionLimit }
|
||||
),
|
||||
[updateOptions]
|
||||
)
|
||||
const setAgentEnabled = useCallback(
|
||||
(agent: AiVaultAgent, enabled: boolean) => {
|
||||
updateOptions((current) => {
|
||||
|
|
@ -110,9 +120,11 @@ export function usePersistedAiVaultViewOptions(): {
|
|||
sort: options.sort,
|
||||
group: options.group,
|
||||
hideEmptySessions: options.hideEmptySessions,
|
||||
sessionLimit: options.sessionLimit,
|
||||
setSort,
|
||||
setGroup,
|
||||
setHideEmptySessions,
|
||||
setSessionLimit,
|
||||
setAgentEnabled,
|
||||
setAllAgentsEnabled,
|
||||
resetViewOptions
|
||||
|
|
|
|||
|
|
@ -11475,6 +11475,14 @@
|
|||
},
|
||||
"activityBar": {
|
||||
"error": "Error"
|
||||
},
|
||||
"AiVaultSessionLimitMenu": {
|
||||
"historyDepth": "History depth: {{value0}}",
|
||||
"performanceWarning": "Larger histories can slow the entire app, especially on remote hosts. Unlimited scans all available history.",
|
||||
"recommended": "Recommended",
|
||||
"mayBeSlower": "May be slower",
|
||||
"slowest": "Slowest",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11335,6 +11335,14 @@
|
|||
},
|
||||
"activityBar": {
|
||||
"error": "Error"
|
||||
},
|
||||
"AiVaultSessionLimitMenu": {
|
||||
"historyDepth": "Profundidad del historial: {{value0}}",
|
||||
"performanceWarning": "Los historiales más extensos pueden ralentizar toda la aplicación, especialmente en hosts remotos. Sin límite escanea todo el historial disponible.",
|
||||
"recommended": "Recomendado",
|
||||
"mayBeSlower": "Puede ser más lento",
|
||||
"slowest": "Más lento",
|
||||
"unlimited": "Sin límite"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11335,6 +11335,14 @@
|
|||
},
|
||||
"activityBar": {
|
||||
"error": "エラー"
|
||||
},
|
||||
"AiVaultSessionLimitMenu": {
|
||||
"historyDepth": "履歴の件数: {{value0}}",
|
||||
"performanceWarning": "履歴を増やすと、特にリモートホストではアプリ全体が遅くなる場合があります。無制限では利用可能な履歴をすべてスキャンします。",
|
||||
"recommended": "推奨",
|
||||
"mayBeSlower": "遅くなる可能性あり",
|
||||
"slowest": "最も低速",
|
||||
"unlimited": "無制限"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11335,6 +11335,14 @@
|
|||
},
|
||||
"activityBar": {
|
||||
"error": "오류"
|
||||
},
|
||||
"AiVaultSessionLimitMenu": {
|
||||
"historyDepth": "기록 개수: {{value0}}",
|
||||
"performanceWarning": "더 많은 기록을 불러오면 특히 원격 호스트에서 앱 전체가 느려질 수 있습니다. 무제한은 사용 가능한 모든 기록을 스캔합니다.",
|
||||
"recommended": "권장",
|
||||
"mayBeSlower": "느려질 수 있음",
|
||||
"slowest": "가장 느림",
|
||||
"unlimited": "무제한"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11347,6 +11347,14 @@
|
|||
},
|
||||
"activityBar": {
|
||||
"error": "错误"
|
||||
},
|
||||
"AiVaultSessionLimitMenu": {
|
||||
"historyDepth": "历史记录数量:{{value0}}",
|
||||
"performanceWarning": "加载更多历史记录可能会拖慢整个应用,尤其是在远程主机上。无限制模式会扫描所有可用历史记录。",
|
||||
"recommended": "推荐",
|
||||
"mayBeSlower": "可能较慢",
|
||||
"slowest": "最慢",
|
||||
"unlimited": "无限制"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1530,6 +1530,10 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
|
|||
executionHostId
|
||||
})
|
||||
},
|
||||
// Why: the runtime RPC transport has no cancel verb, so the in-flight scan
|
||||
// settles on its own timeout. The renderer's refreshId guard already drops
|
||||
// the late result; this only means web pays for a scan nobody reads.
|
||||
cancelListSessions: () => Promise.resolve(),
|
||||
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
|
||||
callRuntimeResult<AiVaultPrepareSessionResumeResult>('aiVault.prepareSessionResume', args),
|
||||
// Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from './ai-vault-types'
|
||||
import {
|
||||
aiVaultScanLimit,
|
||||
aiVaultSessionDepthCovers,
|
||||
requestedAiVaultSessionDepth,
|
||||
truncateAiVaultListResult
|
||||
} from './ai-vault-session-depth'
|
||||
|
||||
function session(id: string, cwd: string, index: number): AiVaultSession {
|
||||
const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString()
|
||||
return {
|
||||
id,
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: id,
|
||||
title: id,
|
||||
cwd,
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${id}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
modifiedAt: timestamp,
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: id,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: '2026-08-02T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
describe('Agent Session History depth', () => {
|
||||
it('recognizes which loaded depths cover a request', () => {
|
||||
expect(aiVaultSessionDepthCovers(250, 250)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers(1000, 250)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers(250, 500)).toBe(false)
|
||||
expect(aiVaultSessionDepthCovers(1000, 'unlimited')).toBe(false)
|
||||
expect(aiVaultSessionDepthCovers('unlimited', 1000)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers('unlimited', 'unlimited')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes default, finite, and unlimited requests', () => {
|
||||
expect(requestedAiVaultSessionDepth()).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500 })).toBe(500)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500.75 })).toBe(500)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 0 })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: -1 })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: Number.NaN })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500, unlimited: true })).toBe('unlimited')
|
||||
})
|
||||
|
||||
it('resolves scan limits to a numeric bound', () => {
|
||||
expect(aiVaultScanLimit({ limit: 500 })).toBe(500)
|
||||
expect(aiVaultScanLimit({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
|
||||
expect(aiVaultScanLimit({ unlimited: true })).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
it('keeps the newest global and scoped sessions when truncating', () => {
|
||||
const loaded = result([
|
||||
session('global-1', '/other', 6),
|
||||
session('global-2', '/other', 5),
|
||||
session('global-3', '/other', 4),
|
||||
session('scoped-1', '/repo/app', 3),
|
||||
session('scoped-2', '/repo/lib', 2),
|
||||
session('scoped-3', '/repo/old', 1)
|
||||
])
|
||||
|
||||
expect(truncateAiVaultListResult(loaded, 2, ['/repo']).sessions.map(({ id }) => id)).toEqual([
|
||||
'global-1',
|
||||
'global-2',
|
||||
'scoped-1',
|
||||
'scoped-2'
|
||||
])
|
||||
expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { isPathInsideOrEqual } from './cross-platform-path'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types'
|
||||
|
||||
export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000
|
||||
|
||||
export type AiVaultSessionDepth = number | 'unlimited'
|
||||
|
||||
export function requestedAiVaultSessionDepth(
|
||||
args?: Pick<AiVaultListArgs, 'limit' | 'unlimited'>
|
||||
): AiVaultSessionDepth {
|
||||
if (args?.unlimited === true) {
|
||||
return 'unlimited'
|
||||
}
|
||||
return args?.limit && Number.isFinite(args.limit) && args.limit > 0
|
||||
? Math.floor(args.limit)
|
||||
: DEFAULT_AI_VAULT_SCAN_LIMIT
|
||||
}
|
||||
|
||||
// Scanners bound with slice/comparisons, so 'unlimited' resolves to no bound.
|
||||
// Single owner of the normalization every scan limit goes through.
|
||||
export function aiVaultScanLimit(args?: Pick<AiVaultListArgs, 'limit' | 'unlimited'>): number {
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
return depth === 'unlimited' ? Number.POSITIVE_INFINITY : depth
|
||||
}
|
||||
|
||||
export function aiVaultSessionDepthCovers(
|
||||
cached: AiVaultSessionDepth,
|
||||
requested: AiVaultSessionDepth
|
||||
): boolean {
|
||||
return cached === 'unlimited' || (requested !== 'unlimited' && cached >= requested)
|
||||
}
|
||||
|
||||
export function truncateAiVaultListResult(
|
||||
result: AiVaultListResult,
|
||||
depth: AiVaultSessionDepth,
|
||||
scopePaths: readonly string[] = []
|
||||
): AiVaultListResult {
|
||||
if (depth === 'unlimited') {
|
||||
return result
|
||||
}
|
||||
const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id))
|
||||
if (scopePaths.length > 0) {
|
||||
let scopedCount = 0
|
||||
for (const session of result.sessions) {
|
||||
const cwd = session.cwd
|
||||
if (cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) {
|
||||
selectedIds.add(session.id)
|
||||
if (++scopedCount >= depth) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...result, sessions: result.sessions.filter((session) => selectedIds.has(session.id)) }
|
||||
}
|
||||
|
|
@ -25,6 +25,17 @@ export const AI_VAULT_AGENTS = [
|
|||
// value are optional belt-and-braces, not required for the request to succeed.
|
||||
export const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64
|
||||
|
||||
// Why: IPC rejection drops `Error.name`, so the renderer can only recognise a
|
||||
// cancelled scan by its message. Both sides share this literal.
|
||||
export const AI_VAULT_SCAN_CANCELLED_MESSAGE = 'Agent Session History scan was cancelled'
|
||||
|
||||
export function isAiVaultScanCancelledError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' || error.message.includes(AI_VAULT_SCAN_CANCELLED_MESSAGE))
|
||||
)
|
||||
}
|
||||
|
||||
export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number]
|
||||
export type AiVaultScope = 'workspace' | 'project' | 'all'
|
||||
export type AiVaultSort = 'updated' | 'created'
|
||||
|
|
@ -172,23 +183,29 @@ export function isAiVaultSessionRecoverableEmpty(
|
|||
export type AiVaultScanIssue = {
|
||||
executionHostId?: ExecutionHostId
|
||||
agent: AiVaultAgent
|
||||
// 'notice' rows are scanner commentary (issue-list overflow), never a failure.
|
||||
kind?: 'host' | 'scope' | 'notice'
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AiVaultListArgs = {
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
force?: boolean
|
||||
// Active workspace/project paths. The global result is recency-capped, so these
|
||||
// guarantee a scoped view still surfaces its own (possibly older) sessions.
|
||||
scopePaths?: readonly string[]
|
||||
executionHostScope?: ExecutionHostScope
|
||||
requestToken?: string
|
||||
}
|
||||
|
||||
export type AiVaultListResult = {
|
||||
sessions: AiVaultSession[]
|
||||
issues: AiVaultScanIssue[]
|
||||
scannedAt: string
|
||||
/** Set only by the desktop IPC boundary: this scan was superseded, so its empty body means "nothing to apply". */
|
||||
cancelled?: true
|
||||
}
|
||||
|
||||
export function aiVaultAgentLabel(agent: AiVaultAgent): string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const
|
||||
export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000
|
||||
export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
|
||||
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
|
||||
|
||||
export type SshAiVaultRelayListParams = {
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
force?: boolean
|
||||
scopePaths?: string[]
|
||||
scopePathsTruncated?: boolean
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export async function connectDockerRemote(
|
|||
page: Page,
|
||||
target: DockerSshRelayTarget
|
||||
): Promise<ConnectedDockerRemote> {
|
||||
return await page.evaluate(
|
||||
const remote = await page.evaluate(
|
||||
async ({ target, remotePath }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
|
|
@ -70,22 +70,44 @@ export async function connectDockerRemote(
|
|||
}
|
||||
await store.getState().fetchRepos()
|
||||
await store.getState().fetchWorktrees(result.repo.id)
|
||||
const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0]
|
||||
if (!worktree) {
|
||||
throw new Error(`No remote worktree found for ${result.repo.path}`)
|
||||
}
|
||||
store.getState().setActiveWorktree(worktree.id)
|
||||
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
|
||||
store.getState().createTab(worktree.id)
|
||||
}
|
||||
store.getState().setActiveTabType('terminal')
|
||||
return { targetId: createdTarget.id, worktreeId: worktree.id }
|
||||
return { targetId: createdTarget.id, repoId: result.repo.id, repoPath: result.repo.path }
|
||||
} finally {
|
||||
credentialUnsub()
|
||||
}
|
||||
},
|
||||
{ target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH }
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async (repoId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return 0
|
||||
}
|
||||
await store.getState().fetchWorktrees(repoId)
|
||||
return store.getState().worktreesByRepo[repoId]?.length ?? 0
|
||||
}, remote.repoId),
|
||||
{ timeout: 30_000, message: `No remote worktree found for ${remote.repoPath}` }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const worktreeId = await page.evaluate((repoId) => {
|
||||
const store = window.__store
|
||||
const worktree = store?.getState().worktreesByRepo[repoId]?.[0]
|
||||
if (!store || !worktree) {
|
||||
throw new Error(`Remote worktree disappeared for repo ${repoId}`)
|
||||
}
|
||||
store.getState().setActiveWorktree(worktree.id)
|
||||
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
|
||||
store.getState().createTab(worktree.id)
|
||||
}
|
||||
store.getState().setActiveTabType('terminal')
|
||||
return worktree.id
|
||||
}, remote.repoId)
|
||||
|
||||
return { targetId: remote.targetId, worktreeId }
|
||||
}
|
||||
|
||||
export async function switchToNonRemoteWorktree(
|
||||
|
|
|
|||
Loading…
Reference in New Issue