perf(ai-vault): isolate tab title resolution (#13377)
* perf(ai-vault): isolate tab title resolution * fix(ai-vault): preserve background scan caches * fix(ai-vault): resolve nested worker from chunks
This commit is contained in:
parent
a1f61ef8c0
commit
5df2ddbc9c
|
|
@ -37,6 +37,7 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
|||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
|
|
|
|||
|
|
@ -4830,24 +4830,33 @@
|
|||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart (which emits no pty:exit). Broader terminal interaction coverage remains on the pending reliability stack.",
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart. AI Vault tab-title sync uses bounded exact identities on the transcript-owning local, SSH, or runtime host and has no access to broad listing; unsupported older hosts retain persisted titles without fallback scans. WSL path translation still needs a Windows integration oracle.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/issues/9386",
|
||||
"https://github.com/stablyai/orca/pull/9387"
|
||||
"https://github.com/stablyai/orca/pull/9387",
|
||||
"https://github.com/stablyai/orca/pull/12778"
|
||||
],
|
||||
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
|
||||
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
|
||||
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"src/renderer/src/lib/ai-vault-tab-title-sync.test.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-client.test.ts",
|
||||
"src/main/ai-vault/session-title-file-reader.test.ts",
|
||||
"src/main/ai-vault/session-parse-cache-persistence.test.ts",
|
||||
"src/main/ipc/ai-vault.test.ts",
|
||||
"src/main/runtime/rpc/methods/ai-vault.test.ts",
|
||||
"src/relay/ai-vault-handler.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
|
|
@ -4881,6 +4890,38 @@
|
|||
"the closed inventory hook installs no interval",
|
||||
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts",
|
||||
"assertions": [
|
||||
"title sync invokes only exact resolveSessionTitles requests",
|
||||
"status pings and worktree path changes do not trigger rereads",
|
||||
"title requests are bounded to 64 identities per batch",
|
||||
"independent hosts resolve concurrently while each host wire stays serial"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ai-vault/session-scanner-worker-client.test.ts",
|
||||
"assertions": [
|
||||
"full scans and exact-title reads share one serial FIFO worker",
|
||||
"active cancellation stays serialized and queued work remains bounded",
|
||||
"worker faults restart queued work and idle time preserves incremental parse state",
|
||||
"worker disposal rejects retained work and terminates the worker"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ai-vault/session-parse-cache-persistence.test.ts",
|
||||
"assertions": [
|
||||
"the background scanner receives the configured persisted-cache identity",
|
||||
"unchanged transcripts remain reusable across process restarts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ai-vault.test.ts",
|
||||
"assertions": [
|
||||
"exact title identities route to local, SSH, and runtime host authority",
|
||||
"unsupported remote hosts return no refresh and never fall back to broad scans"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
|
|
@ -4892,6 +4933,15 @@
|
|||
"result": "passed",
|
||||
"durationSeconds": 4.3,
|
||||
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.1,
|
||||
"summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
|
|
@ -4904,11 +4954,11 @@
|
|||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "This gate is the performance budget for global session listing in hot paths."
|
||||
"evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Add deterministic call-count instrumentation.",
|
||||
|
|
@ -4918,7 +4968,8 @@
|
|||
"knownGaps": [
|
||||
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
|
||||
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
|
||||
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
|
||||
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero PTY listSessions calls.",
|
||||
"The exact-title path has deterministic provider routing coverage but no packaged Windows WSL or live SSH performance run yet."
|
||||
],
|
||||
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -217,6 +217,9 @@ export const electronViteConfig: UserConfig = {
|
|||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
|
||||
const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() }))
|
||||
const { scanAiVaultSessionsInWorker } = vi.hoisted(() => ({
|
||||
scanAiVaultSessionsInWorker: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-scanner', () => ({ scanAiVaultSessions }))
|
||||
vi.mock('./session-scanner-worker-spawn', () => ({
|
||||
scanAiVaultSessionsInWorker,
|
||||
resetAiVaultScannerWorkerForTests: vi.fn()
|
||||
}))
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: vi.fn(),
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
|
|
@ -23,7 +28,7 @@ function scanResult(scannedAt: string): AiVaultListResult {
|
|||
// mid-flight.
|
||||
function deferredScan(): { resolve: (value: AiVaultListResult) => void } {
|
||||
let resolveFn: (value: AiVaultListResult) => void = () => {}
|
||||
scanAiVaultSessions.mockReturnValueOnce(
|
||||
scanAiVaultSessionsInWorker.mockReturnValueOnce(
|
||||
new Promise<AiVaultListResult>((resolve) => {
|
||||
resolveFn = resolve
|
||||
})
|
||||
|
|
@ -34,7 +39,7 @@ function deferredScan(): { resolve: (value: AiVaultListResult) => void } {
|
|||
describe('invalidateAiVaultSessionListCache generation guard', () => {
|
||||
beforeEach(() => {
|
||||
resetAiVaultSessionListCacheForTests()
|
||||
scanAiVaultSessions.mockReset()
|
||||
scanAiVaultSessionsInWorker.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
resetAiVaultSessionListCacheForTests()
|
||||
|
|
@ -54,21 +59,21 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
|
|||
|
||||
// A non-force list must re-scan (cache empty) rather than serve A's stale
|
||||
// result — proof A's late .then() did not repopulate the cache.
|
||||
scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-B'))
|
||||
scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-B'))
|
||||
const next = await listAiVaultSessions()
|
||||
|
||||
expect(next.scannedAt).toBe('scan-B')
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(2)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('caches normally when no invalidation interrupts the scan', async () => {
|
||||
scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-A'))
|
||||
scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-A'))
|
||||
await listAiVaultSessions()
|
||||
|
||||
// Second non-force call is a cache hit — no second scan.
|
||||
const cached = await listAiVaultSessions()
|
||||
|
||||
expect(cached.scannedAt).toBe('scan-A')
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { join } from 'node:path'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
|
|
@ -74,19 +77,19 @@ export async function listAiVaultSessions(
|
|||
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
|
||||
})
|
||||
const result = await scanAiVaultSessionsInWorker(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
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
|
||||
},
|
||||
scanSignal
|
||||
)
|
||||
// A delete (or other invalidation) landed while this scan was running:
|
||||
// its result predates the delete, so caching it would resurrect the
|
||||
// deleted session for the TTL. Return it to this caller but don't cache.
|
||||
|
|
@ -139,4 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void {
|
|||
invalidateAiVaultSessionListCache()
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
sources = {}
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import type {
|
|||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
} from '../../shared/ai-vault-resume-preparation'
|
||||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { parseAiVaultListResult } from './session-list-result-validation'
|
||||
import { parseAiVaultSessionTitlesResult } from './session-title-result-validation'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
|
|
@ -102,6 +107,27 @@ export async function scanRuntimeAiVaultSessions(
|
|||
})
|
||||
}
|
||||
|
||||
export async function resolveRuntimeAiVaultSessionTitles(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
args: AiVaultSessionTitlesArgs
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'aiVault.resolveSessionTitles',
|
||||
{ requests: args.requests }
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
return { titles: [] }
|
||||
}
|
||||
try {
|
||||
return parseAiVaultSessionTitlesResult(response.result)
|
||||
} catch {
|
||||
return { titles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareRuntimeAiVaultSessionResume(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import {
|
||||
ensureSessionParseCacheLoaded,
|
||||
flushSessionParseCachePersistForTests,
|
||||
getSessionParseCachePersistenceOptions,
|
||||
initSessionParseCachePersistence,
|
||||
resetSessionParseCachePersistenceForTests,
|
||||
scheduleSessionParseCachePersist
|
||||
|
|
@ -126,6 +127,16 @@ async function coldParseStats(path: string): Promise<SessionParseStats> {
|
|||
}
|
||||
|
||||
describe('session parse cache persistence', () => {
|
||||
it('exposes a copy of the active configuration for background scanners', () => {
|
||||
const configured = { filePath: '/tmp/ai-vault-cache.json', appVersion: APP_VERSION }
|
||||
initSessionParseCachePersistence(configured)
|
||||
|
||||
const snapshot = getSessionParseCachePersistenceOptions()
|
||||
|
||||
expect(snapshot).toEqual(configured)
|
||||
expect(snapshot).not.toBe(configured)
|
||||
})
|
||||
|
||||
it('round-trips: a persisted entry is a reused hit after a restart, without reading the transcript', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'vault-state', 'session-parse-cache.json')
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const SAVE_DEBOUNCE_MS = 1_500
|
|||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const PRIVATE_FILE_MODE = 0o600
|
||||
|
||||
type SessionParseCachePersistenceOptions = {
|
||||
export type SessionParseCachePersistenceOptions = {
|
||||
filePath: string
|
||||
appVersion: string
|
||||
}
|
||||
|
|
@ -36,6 +36,10 @@ export function initSessionParseCachePersistence(next: SessionParseCachePersiste
|
|||
options = next
|
||||
}
|
||||
|
||||
export function getSessionParseCachePersistenceOptions(): SessionParseCachePersistenceOptions | null {
|
||||
return options ? { ...options } : null
|
||||
}
|
||||
|
||||
export function resetSessionParseCachePersistenceForTests(): void {
|
||||
options = null
|
||||
loadPromise = null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveOpenCodeSqliteWorkerEntryPath } from './session-scanner-opencode-sqlite-worker-spawn'
|
||||
|
||||
const ENTRY = 'session-scanner-opencode-sqlite-worker-entry.js'
|
||||
|
||||
describe('resolveOpenCodeSqliteWorkerEntryPath', () => {
|
||||
it('resolves a worker adjacent to an entry bundle', () => {
|
||||
const runtimeDir = join('out', 'main')
|
||||
const expected = join(runtimeDir, ENTRY)
|
||||
|
||||
expect(resolveOpenCodeSqliteWorkerEntryPath(runtimeDir, (path) => path === expected)).toBe(
|
||||
expected
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a root worker from a shared Rollup chunk', () => {
|
||||
const runtimeDir = join('out', 'main', 'chunks')
|
||||
const expected = join('out', 'main', ENTRY)
|
||||
|
||||
expect(resolveOpenCodeSqliteWorkerEntryPath(runtimeDir, (path) => path === expected)).toBe(
|
||||
expected
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -9,27 +9,23 @@ import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-wo
|
|||
// the client class stays free of Electron (require'd lazily here) and the
|
||||
// scanner call sites depend only on the two routing functions below.
|
||||
|
||||
function resolveWorkerEntryPath(): string {
|
||||
let app: { isPackaged: boolean } | null = null
|
||||
try {
|
||||
app = require('electron').app ?? null
|
||||
} catch {
|
||||
app = null
|
||||
}
|
||||
if (app?.isPackaged) {
|
||||
return join(
|
||||
process.resourcesPath,
|
||||
'app.asar',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-opencode-sqlite-worker-entry.js'
|
||||
)
|
||||
}
|
||||
return join(__dirname, 'session-scanner-opencode-sqlite-worker-entry.js')
|
||||
const WORKER_ENTRY_FILENAME = 'session-scanner-opencode-sqlite-worker-entry.js'
|
||||
|
||||
export function resolveOpenCodeSqliteWorkerEntryPath(
|
||||
runtimeDir = __dirname,
|
||||
pathExists: (path: string) => boolean = existsSync
|
||||
): string {
|
||||
const candidates = [
|
||||
join(runtimeDir, WORKER_ENTRY_FILENAME),
|
||||
// Rollup factors this launcher into out/main/chunks when the outer scanner
|
||||
// worker and main entry both import it; worker entries remain in out/main.
|
||||
join(runtimeDir, '..', WORKER_ENTRY_FILENAME)
|
||||
]
|
||||
return candidates.find(pathExists) ?? candidates[0]!
|
||||
}
|
||||
|
||||
function defaultWorkerFactory(): Worker {
|
||||
const workerPath = resolveWorkerEntryPath()
|
||||
const workerPath = resolveOpenCodeSqliteWorkerEntryPath()
|
||||
// Why: a missing built entry must throw synchronously so the client can fail
|
||||
// closed before it waits on a worker that can never post a result.
|
||||
if (!existsSync(workerPath)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
import type { Worker } from 'node:worker_threads'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AiVaultWorkerControl,
|
||||
AiVaultWorkerRequest,
|
||||
AiVaultWorkerResponse
|
||||
} from './session-scanner-worker-protocol'
|
||||
import { AiVaultScannerWorkerClient } from './session-scanner-worker-client'
|
||||
|
||||
class FakeWorker {
|
||||
readonly posted: (AiVaultWorkerRequest | AiVaultWorkerControl)[] = []
|
||||
terminated = false
|
||||
unrefed = false
|
||||
private readonly listeners = new Map<string, Set<(value: unknown) => void>>()
|
||||
|
||||
on(event: string, listener: (value: unknown) => void): this {
|
||||
const listeners = this.listeners.get(event) ?? new Set()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(event, listeners)
|
||||
return this
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
postMessage(message: AiVaultWorkerRequest | AiVaultWorkerControl): void {
|
||||
this.posted.push(message)
|
||||
}
|
||||
|
||||
unref(): void {
|
||||
this.unrefed = true
|
||||
}
|
||||
|
||||
async terminate(): Promise<number> {
|
||||
this.terminated = true
|
||||
return 1
|
||||
}
|
||||
|
||||
emit(event: string, value: unknown): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(): { client: AiVaultScannerWorkerClient; worker: FakeWorker } {
|
||||
const worker = new FakeWorker()
|
||||
return {
|
||||
client: new AiVaultScannerWorkerClient({
|
||||
workerFactory: () => worker as unknown as Worker
|
||||
}),
|
||||
worker
|
||||
}
|
||||
}
|
||||
|
||||
function setupWorkerFactory(): {
|
||||
client: AiVaultScannerWorkerClient
|
||||
workers: FakeWorker[]
|
||||
} {
|
||||
const workers: FakeWorker[] = []
|
||||
return {
|
||||
client: new AiVaultScannerWorkerClient({
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
}
|
||||
}),
|
||||
workers
|
||||
}
|
||||
}
|
||||
|
||||
function titleResponse(
|
||||
id: number,
|
||||
title: string
|
||||
): Extract<AiVaultWorkerResponse, { kind: 'titles' }> {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
kind: 'titles',
|
||||
value: { titles: [{ agent: 'codex', sessionId: 'session', title }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('AiVaultScannerWorkerClient', () => {
|
||||
it('serializes requests in FIFO order and ignores stale responses', async () => {
|
||||
const { client, worker } = setup()
|
||||
const first = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }])
|
||||
const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }])
|
||||
|
||||
expect(worker.posted).toHaveLength(1)
|
||||
const firstId = worker.posted[0]!.id
|
||||
worker.emit('message', titleResponse(999, 'stale'))
|
||||
expect(worker.posted).toHaveLength(1)
|
||||
|
||||
worker.emit('message', titleResponse(firstId, 'first'))
|
||||
await expect(first).resolves.toEqual(titleResponse(firstId, 'first').value)
|
||||
expect(worker.posted).toHaveLength(2)
|
||||
|
||||
const secondId = worker.posted[1]!.id
|
||||
worker.emit('message', titleResponse(secondId, 'second'))
|
||||
await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value)
|
||||
expect(worker.unrefed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('cancels active work without dispatching the next call concurrently', async () => {
|
||||
const { client, worker } = setup()
|
||||
const controller = new AbortController()
|
||||
const first = client.resolveTitles(
|
||||
[{ agent: 'codex', sessionId: 'session' }],
|
||||
controller.signal
|
||||
)
|
||||
const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }])
|
||||
const firstId = worker.posted[0]!.id
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(worker.posted).toEqual([
|
||||
expect.objectContaining({ id: firstId, kind: 'titles' }),
|
||||
{ id: firstId, kind: 'cancel' }
|
||||
])
|
||||
|
||||
worker.emit('message', titleResponse(firstId, 'ignored'))
|
||||
expect(worker.posted).toHaveLength(3)
|
||||
const secondId = worker.posted[2]!.id
|
||||
worker.emit('message', titleResponse(secondId, 'second'))
|
||||
await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['error', new Error('worker crashed')],
|
||||
['exit', 1]
|
||||
] as const)('restarts queued work after a worker %s', async (event, value) => {
|
||||
const { client, workers } = setupWorkerFactory()
|
||||
const first = client.resolveTitles([{ agent: 'codex', sessionId: 'first' }])
|
||||
const second = client.resolveTitles([{ agent: 'claude', sessionId: 'second' }])
|
||||
|
||||
workers[0]!.emit(event, value)
|
||||
|
||||
await expect(first).rejects.toThrow()
|
||||
expect(workers).toHaveLength(2)
|
||||
expect(workers[0]!.terminated).toBe(true)
|
||||
const secondId = workers[1]!.posted[0]!.id
|
||||
workers[1]!.emit('message', titleResponse(secondId, 'second'))
|
||||
await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('keeps the unrefed worker resident so incremental parse state survives idle time', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { client, worker } = setup()
|
||||
const result = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }])
|
||||
const requestId = worker.posted[0]!.id
|
||||
worker.emit('message', titleResponse(requestId, 'title'))
|
||||
await result
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
|
||||
expect(worker.terminated).toBe(false)
|
||||
client.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds queued calls while one request is active', async () => {
|
||||
const { client } = setup()
|
||||
const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }])
|
||||
const queued = Array.from({ length: 16 }, (_, index) =>
|
||||
client.resolveTitles([{ agent: 'codex', sessionId: `queued-${index}` }])
|
||||
)
|
||||
|
||||
await expect(client.resolveTitles([{ agent: 'codex', sessionId: 'overflow' }])).rejects.toThrow(
|
||||
'queue is full'
|
||||
)
|
||||
client.dispose()
|
||||
await expect(active).rejects.toThrow('disposed')
|
||||
await Promise.all(queued.map((promise) => expect(promise).rejects.toThrow('disposed')))
|
||||
})
|
||||
|
||||
it('terminates and rejects active and queued calls on disposal', async () => {
|
||||
const { client, worker } = setup()
|
||||
const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }])
|
||||
const queued = client.resolveTitles([{ agent: 'claude', sessionId: 'queued' }])
|
||||
|
||||
client.dispose()
|
||||
|
||||
expect(worker.terminated).toBe(true)
|
||||
await expect(active).rejects.toThrow('disposed')
|
||||
await expect(queued).rejects.toThrow('disposed')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import type { Worker } from 'node:worker_threads'
|
||||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import type {
|
||||
AiVaultWorkerRequest,
|
||||
AiVaultWorkerResponse,
|
||||
AiVaultWorkerScanOptions
|
||||
} from './session-scanner-worker-protocol'
|
||||
|
||||
const SCAN_TIMEOUT_MS = 130_000
|
||||
const TITLE_TIMEOUT_MS = 15_000
|
||||
const MAX_QUEUED_CALLS = 16
|
||||
|
||||
export type AiVaultWorkerFactory = () => Worker
|
||||
|
||||
type RequestBody =
|
||||
| Omit<Extract<AiVaultWorkerRequest, { kind: 'scan' }>, 'id'>
|
||||
| Omit<Extract<AiVaultWorkerRequest, { kind: 'titles' }>, 'id'>
|
||||
|
||||
type PendingCall = {
|
||||
request: AiVaultWorkerRequest
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export class AiVaultScannerWorkerClient {
|
||||
private worker: Worker | null = null
|
||||
private active: PendingCall | null = null
|
||||
private queue: PendingCall[] = []
|
||||
private nextId = 1
|
||||
private readonly workerFactory: AiVaultWorkerFactory
|
||||
|
||||
constructor(options: { workerFactory: AiVaultWorkerFactory }) {
|
||||
this.workerFactory = options.workerFactory
|
||||
}
|
||||
|
||||
scan(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ result: AiVaultListResult; durationMs: number }> {
|
||||
return this.dispatch({ kind: 'scan', options }, SCAN_TIMEOUT_MS, signal) as Promise<{
|
||||
result: AiVaultListResult
|
||||
durationMs: number
|
||||
}>
|
||||
}
|
||||
|
||||
resolveTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return this.dispatch(
|
||||
{ kind: 'titles', requests },
|
||||
TITLE_TIMEOUT_MS,
|
||||
signal
|
||||
) as Promise<AiVaultSessionTitlesResult>
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyWorker()
|
||||
const pending = this.queue
|
||||
this.queue = []
|
||||
for (const call of pending) {
|
||||
this.rejectCall(call, new Error('AI Vault scanner worker was disposed.'))
|
||||
}
|
||||
if (this.active) {
|
||||
this.rejectCall(this.active, new Error('AI Vault scanner worker was disposed.'))
|
||||
this.active = null
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(body: RequestBody, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAiVaultScanCancelledError())
|
||||
}
|
||||
if (this.queue.length >= MAX_QUEUED_CALLS) {
|
||||
return Promise.reject(new Error('AI Vault scanner worker queue is full.'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const call: PendingCall = {
|
||||
request: { ...body, id: this.nextId++ } as AiVaultWorkerRequest,
|
||||
timeoutMs,
|
||||
signal,
|
||||
resolve,
|
||||
reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
cancelled: false
|
||||
}
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.active || this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
const worker = this.ensureWorker()
|
||||
if (!worker) {
|
||||
this.failQueue(new Error('AI Vault background scanner could not start.'))
|
||||
return
|
||||
}
|
||||
const call = this.queue.shift()
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active = call
|
||||
call.timer = setTimeout(() => {
|
||||
this.onWorkerFault(new Error(`AI Vault scanner worker timed out after ${call.timeoutMs}ms.`))
|
||||
}, call.timeoutMs)
|
||||
call.timer.unref?.()
|
||||
worker.postMessage(call.request)
|
||||
}
|
||||
|
||||
private ensureWorker(): Worker | null {
|
||||
if (this.worker) {
|
||||
return this.worker
|
||||
}
|
||||
try {
|
||||
const worker = this.workerFactory()
|
||||
worker.on('message', (response: AiVaultWorkerResponse) => this.onMessage(response))
|
||||
worker.on('error', (error: Error) => this.onWorkerFault(error))
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0 || this.active || this.queue.length > 0) {
|
||||
this.onWorkerFault(new Error(`AI Vault scanner worker exited with code ${code}.`))
|
||||
} else {
|
||||
this.destroyWorker()
|
||||
}
|
||||
})
|
||||
worker.unref?.()
|
||||
this.worker = worker
|
||||
return worker
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(response: AiVaultWorkerResponse): void {
|
||||
const call = this.active
|
||||
if (!call || call.request.id !== response.id) {
|
||||
return
|
||||
}
|
||||
this.active = null
|
||||
this.clearCall(call)
|
||||
if (!call.cancelled) {
|
||||
if (response.ok) {
|
||||
call.resolve(response.value)
|
||||
} else {
|
||||
call.reject(new Error(response.error))
|
||||
}
|
||||
}
|
||||
this.afterSettle()
|
||||
}
|
||||
|
||||
private cancel(call: PendingCall): void {
|
||||
if (call.cancelled) {
|
||||
return
|
||||
}
|
||||
call.cancelled = true
|
||||
call.reject(createAiVaultScanCancelledError())
|
||||
if (this.active === call) {
|
||||
this.worker?.postMessage({ id: call.request.id, kind: 'cancel' })
|
||||
return
|
||||
}
|
||||
const index = this.queue.indexOf(call)
|
||||
if (index >= 0) {
|
||||
this.queue.splice(index, 1)
|
||||
this.clearCall(call)
|
||||
}
|
||||
}
|
||||
|
||||
private onWorkerFault(error: Error): void {
|
||||
const active = this.active
|
||||
this.active = null
|
||||
this.destroyWorker()
|
||||
if (active) {
|
||||
this.rejectCall(active, error)
|
||||
}
|
||||
if (this.queue.length > 0) {
|
||||
this.pump()
|
||||
}
|
||||
}
|
||||
|
||||
private rejectCall(call: PendingCall, error: Error): void {
|
||||
this.clearCall(call)
|
||||
if (!call.cancelled) {
|
||||
call.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
private failQueue(error: Error): void {
|
||||
const pending = this.queue
|
||||
this.queue = []
|
||||
for (const call of pending) {
|
||||
this.rejectCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private clearCall(call: PendingCall): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
call.onAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
private afterSettle(): void {
|
||||
if (this.queue.length > 0) {
|
||||
this.pump()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyWorker(): void {
|
||||
const worker = this.worker
|
||||
this.worker = null
|
||||
if (!worker) {
|
||||
return
|
||||
}
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import type {
|
||||
AiVaultSessionTitle,
|
||||
AiVaultSessionTitleRequest
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { initSessionParseCachePersistence } from './session-parse-cache-persistence'
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
|
||||
import type {
|
||||
AiVaultWorkerControl,
|
||||
AiVaultWorkerData,
|
||||
AiVaultWorkerRequest,
|
||||
AiVaultWorkerResponse
|
||||
} from './session-scanner-worker-protocol'
|
||||
|
||||
const TITLE_INDEX_MAX_ENTRIES = 4_096
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error('AI Vault scanner worker must run with a parent port.')
|
||||
}
|
||||
const port = parentPort
|
||||
const data = workerData as AiVaultWorkerData | undefined
|
||||
if (data?.sessionParseCache) {
|
||||
initSessionParseCachePersistence(data.sessionParseCache)
|
||||
}
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const titleIndex = new Map<string, AiVaultSessionTitle>()
|
||||
|
||||
function titleKey(request: Pick<AiVaultSessionTitleRequest, 'agent' | 'sessionId'>): string {
|
||||
return `${request.agent}\0${request.sessionId}`
|
||||
}
|
||||
|
||||
function storeTitle(title: AiVaultSessionTitle): void {
|
||||
const key = titleKey(title)
|
||||
titleIndex.delete(key)
|
||||
titleIndex.set(key, title)
|
||||
while (titleIndex.size > TITLE_INDEX_MAX_ENTRIES) {
|
||||
const oldest = titleIndex.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
titleIndex.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(request: AiVaultWorkerRequest): Promise<AiVaultWorkerResponse> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
try {
|
||||
if (request.kind === 'titles') {
|
||||
const requests = await resolveHostReadableAiVaultTitleRequests(
|
||||
request.requests,
|
||||
controller.signal
|
||||
)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
kind: 'titles',
|
||||
value: await readAiVaultSessionTitlesFromFiles(requests, {
|
||||
signal: controller.signal,
|
||||
cache: {
|
||||
get: (titleRequest) => titleIndex.get(titleKey(titleRequest)) ?? null,
|
||||
set: storeTitle
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal })
|
||||
for (const session of result.sessions) {
|
||||
if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) {
|
||||
storeTitle({
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId,
|
||||
title: session.title.trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
kind: 'scan',
|
||||
value: { result, durationMs: performance.now() - startedAt }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
let pending = Promise.resolve()
|
||||
port.on('message', (message: AiVaultWorkerRequest | AiVaultWorkerControl) => {
|
||||
if (message.kind === 'cancel') {
|
||||
controllers.get(message.id)?.abort()
|
||||
return
|
||||
}
|
||||
pending = pending.then(async () => {
|
||||
const response = await handleRequest(message)
|
||||
try {
|
||||
port.postMessage(response)
|
||||
} catch {
|
||||
port.postMessage({
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: 'AI Vault worker result could not be serialized.'
|
||||
} satisfies AiVaultWorkerResponse)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { AiVaultScanOptions } from './session-scanner-types'
|
||||
import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
|
||||
export type AiVaultWorkerScanOptions = Omit<AiVaultScanOptions, 'signal'>
|
||||
|
||||
export type AiVaultWorkerData = {
|
||||
sessionParseCache: SessionParseCachePersistenceOptions | null
|
||||
}
|
||||
|
||||
export type AiVaultWorkerRequest =
|
||||
| { id: number; kind: 'scan'; options: AiVaultWorkerScanOptions }
|
||||
| { id: number; kind: 'titles'; requests: AiVaultSessionTitleRequest[] }
|
||||
|
||||
export type AiVaultWorkerControl = { id: number; kind: 'cancel' }
|
||||
|
||||
export type AiVaultWorkerResponse =
|
||||
| {
|
||||
id: number
|
||||
ok: true
|
||||
kind: 'scan'
|
||||
value: { result: AiVaultListResult; durationMs: number }
|
||||
}
|
||||
| { id: number; ok: true; kind: 'titles'; value: AiVaultSessionTitlesResult }
|
||||
| { id: number; ok: false; error: string }
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import { AiVaultScannerWorkerClient } from './session-scanner-worker-client'
|
||||
import type { AiVaultWorkerData, AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
const WORKER_ENTRY_FILENAME = 'session-scanner-worker-entry.js'
|
||||
|
||||
function defaultWorkerFactory(): Worker {
|
||||
const workerPath = join(__dirname, WORKER_ENTRY_FILENAME)
|
||||
if (!existsSync(workerPath)) {
|
||||
throw new Error(`AI Vault scanner worker entry not found: ${workerPath}`)
|
||||
}
|
||||
return new Worker(workerPath, {
|
||||
workerData: {
|
||||
sessionParseCache: getSessionParseCachePersistenceOptions()
|
||||
} satisfies AiVaultWorkerData
|
||||
})
|
||||
}
|
||||
|
||||
let sharedClient: AiVaultScannerWorkerClient | null = null
|
||||
|
||||
function getSharedClient(): AiVaultScannerWorkerClient {
|
||||
sharedClient ??= new AiVaultScannerWorkerClient({ workerFactory: defaultWorkerFactory })
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
export async function scanAiVaultSessionsInWorker(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return withSpan('aiVault.scan.worker', async (span) => {
|
||||
const { result, durationMs } = await getSharedClient().scan(options, signal)
|
||||
span.setAttribute('workerDurationMs', durationMs)
|
||||
span.setAttribute('sessions', result.sessions.length)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInWorker(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return getSharedClient().resolveTitles(requests, signal)
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerWorkerForTests(): void {
|
||||
sharedClient?.dispose()
|
||||
sharedClient = null
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const parseAgentSessionFileCached = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./session-scanner-parse-cache', () => ({ parseAgentSessionFileCached }))
|
||||
|
||||
const { readAiVaultSessionTitlesFromFiles } = await import('./session-title-file-reader')
|
||||
const { resolveHostReadableAiVaultTitleRequests } = await import('./session-title-request-paths')
|
||||
|
||||
let temporaryRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
parseAgentSessionFileCached.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
async function transcriptPath(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-title-'))
|
||||
temporaryRoots.push(root)
|
||||
const path = join(root, 'session.jsonl')
|
||||
await writeFile(path, '{}\n')
|
||||
return path
|
||||
}
|
||||
|
||||
describe('readAiVaultSessionTitlesFromFiles', () => {
|
||||
it('probes exact transcript paths in the background-scanner layer', async () => {
|
||||
const path = await transcriptPath()
|
||||
|
||||
await expect(
|
||||
resolveHostReadableAiVaultTitleRequests([
|
||||
{ agent: 'codex', sessionId: 'present', transcriptPath: path },
|
||||
{ agent: 'claude', sessionId: 'missing', transcriptPath: `${path}.missing` }
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{ agent: 'codex', sessionId: 'present', transcriptPath: path },
|
||||
{ agent: 'claude', sessionId: 'missing' }
|
||||
])
|
||||
})
|
||||
|
||||
it('reads only the exact requested transcript and validates its identity', async () => {
|
||||
const path = await transcriptPath()
|
||||
parseAgentSessionFileCached.mockResolvedValue({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
title: ' Exact title '
|
||||
})
|
||||
|
||||
await expect(
|
||||
readAiVaultSessionTitlesFromFiles([
|
||||
{ agent: 'codex', sessionId: 'session-1', transcriptPath: path }
|
||||
])
|
||||
).resolves.toEqual({
|
||||
titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }]
|
||||
})
|
||||
expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(1)
|
||||
expect(parseAgentSessionFileCached.mock.calls[0]?.[0]).toMatchObject({
|
||||
agent: 'codex',
|
||||
file: { path }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a transcript whose parsed identity does not match the request', async () => {
|
||||
const path = await transcriptPath()
|
||||
parseAgentSessionFileCached.mockResolvedValue({
|
||||
agent: 'codex',
|
||||
sessionId: 'different-session',
|
||||
title: 'Wrong title'
|
||||
})
|
||||
|
||||
await expect(
|
||||
readAiVaultSessionTitlesFromFiles([
|
||||
{ agent: 'codex', sessionId: 'session-1', transcriptPath: path }
|
||||
])
|
||||
).resolves.toEqual({ titles: [] })
|
||||
})
|
||||
|
||||
it('uses the bounded worker index when no transcript path is available', async () => {
|
||||
const cached = { agent: 'claude' as const, sessionId: 'session-1', title: 'Cached title' }
|
||||
const cache = { get: vi.fn(() => cached), set: vi.fn() }
|
||||
|
||||
await expect(
|
||||
readAiVaultSessionTitlesFromFiles([{ agent: 'claude', sessionId: 'session-1' }], {
|
||||
cache
|
||||
})
|
||||
).resolves.toEqual({ titles: [cached] })
|
||||
expect(parseAgentSessionFileCached).not.toHaveBeenCalled()
|
||||
expect(cache.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps exact transcript reads at 64 identities', async () => {
|
||||
const path = await transcriptPath()
|
||||
parseAgentSessionFileCached.mockResolvedValue({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-0',
|
||||
title: 'Title'
|
||||
})
|
||||
|
||||
await readAiVaultSessionTitlesFromFiles(
|
||||
Array.from({ length: 65 }, (_, index) => ({
|
||||
agent: 'codex' as const,
|
||||
sessionId: `session-${index}`,
|
||||
transcriptPath: path
|
||||
}))
|
||||
)
|
||||
|
||||
expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(64)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { lstat } from 'node:fs/promises'
|
||||
import {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitle,
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { parseAgentSessionFileCached } from './session-scanner-parse-cache'
|
||||
|
||||
const TITLE_PARSE_CONCURRENCY = 4
|
||||
|
||||
export type AiVaultSessionTitleCache = {
|
||||
get(request: AiVaultSessionTitleRequest): AiVaultSessionTitle | null
|
||||
set(title: AiVaultSessionTitle): void
|
||||
}
|
||||
|
||||
async function readOneTitle(
|
||||
request: AiVaultSessionTitleRequest,
|
||||
signal: AbortSignal | undefined,
|
||||
cache?: AiVaultSessionTitleCache
|
||||
): Promise<AiVaultSessionTitle | null> {
|
||||
if (signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const transcriptPath = request.transcriptPath?.trim()
|
||||
if (!transcriptPath) {
|
||||
return cache?.get(request) ?? null
|
||||
}
|
||||
try {
|
||||
const stats = await lstat(transcriptPath)
|
||||
if (!stats.isFile() || signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const session = await parseAgentSessionFileCached(
|
||||
{
|
||||
agent: request.agent,
|
||||
file: {
|
||||
path: transcriptPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
sizeBytes: stats.size
|
||||
},
|
||||
codexHome: null
|
||||
},
|
||||
process.platform
|
||||
)
|
||||
if (
|
||||
signal?.aborted ||
|
||||
session?.agent !== request.agent ||
|
||||
session.sessionId !== request.sessionId ||
|
||||
!session.title.trim()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const title = {
|
||||
agent: request.agent,
|
||||
sessionId: request.sessionId,
|
||||
title: session.title.trim()
|
||||
}
|
||||
cache?.set(title)
|
||||
return title
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAiVaultSessionTitlesFromFiles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
options: { cache?: AiVaultSessionTitleCache; signal?: AbortSignal } = {}
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
const bounded = requests.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
|
||||
const resolved: (AiVaultSessionTitle | null)[] = Array.from(
|
||||
{ length: bounded.length },
|
||||
() => null
|
||||
)
|
||||
let nextIndex = 0
|
||||
const parseNext = async (): Promise<void> => {
|
||||
while (nextIndex < bounded.length && !options.signal?.aborted) {
|
||||
const index = nextIndex++
|
||||
resolved[index] = await readOneTitle(bounded[index]!, options.signal, options.cache)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(TITLE_PARSE_CONCURRENCY, bounded.length) }, parseNext)
|
||||
)
|
||||
return { titles: resolved.filter((title): title is AiVaultSessionTitle => title !== null) }
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title'
|
||||
import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path'
|
||||
|
||||
export function resolveHostReadableAiVaultTitleRequests(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitleRequest[]> {
|
||||
return Promise.all(
|
||||
requests.map(async (request): Promise<AiVaultSessionTitleRequest> => {
|
||||
if (!request.transcriptPath || signal?.aborted) {
|
||||
return request
|
||||
}
|
||||
const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath)
|
||||
return transcriptPath && !signal?.aborted
|
||||
? { ...request, transcriptPath }
|
||||
: { agent: request.agent, sessionId: request.sessionId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { extname } from 'node:path'
|
||||
import { hasUnsafeProviderSessionIdChars } from '../../shared/agent-session-resume'
|
||||
import {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn'
|
||||
|
||||
const TRANSCRIPT_PATH_MAX_LENGTH = 32_768
|
||||
|
||||
function normalizeRequest(request: AiVaultSessionTitleRequest): AiVaultSessionTitleRequest | null {
|
||||
const sessionId = request.sessionId.trim()
|
||||
if (!sessionId || sessionId.length > 512 || hasUnsafeProviderSessionIdChars(sessionId)) {
|
||||
return null
|
||||
}
|
||||
const transcriptPath = request.transcriptPath?.trim()
|
||||
if (
|
||||
!transcriptPath ||
|
||||
transcriptPath.length > TRANSCRIPT_PATH_MAX_LENGTH ||
|
||||
hasUnsafeProviderSessionIdChars(transcriptPath) ||
|
||||
extname(transcriptPath).toLowerCase() !== '.jsonl'
|
||||
) {
|
||||
return { agent: request.agent, sessionId }
|
||||
}
|
||||
return { agent: request.agent, sessionId, transcriptPath }
|
||||
}
|
||||
|
||||
export async function resolveLocalAiVaultSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
const deduped = new Map<string, AiVaultSessionTitleRequest>()
|
||||
for (const request of requests.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) {
|
||||
const normalized = normalizeRequest(request)
|
||||
if (!normalized) {
|
||||
continue
|
||||
}
|
||||
const key = `${normalized.agent}\0${normalized.sessionId}`
|
||||
const previous = deduped.get(key)
|
||||
if (!previous?.transcriptPath || normalized.transcriptPath) {
|
||||
deduped.set(key, normalized)
|
||||
}
|
||||
}
|
||||
return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitle,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
|
||||
export function parseAiVaultSessionTitlesResult(value: unknown): AiVaultSessionTitlesResult {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('expected an object')
|
||||
}
|
||||
const titles = (value as { titles?: unknown }).titles
|
||||
if (!Array.isArray(titles) || titles.length > AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) {
|
||||
throw new Error('expected a bounded titles array')
|
||||
}
|
||||
return { titles: titles.map(parseTitle) }
|
||||
}
|
||||
|
||||
function parseTitle(value: unknown): AiVaultSessionTitle {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('expected a title object')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
(record.agent !== 'claude' && record.agent !== 'codex') ||
|
||||
typeof record.sessionId !== 'string' ||
|
||||
!record.sessionId.trim() ||
|
||||
record.sessionId.length > 512 ||
|
||||
typeof record.title !== 'string' ||
|
||||
!record.title.trim() ||
|
||||
record.title.length > 512
|
||||
) {
|
||||
throw new Error('invalid session title')
|
||||
}
|
||||
return {
|
||||
agent: record.agent,
|
||||
sessionId: record.sessionId,
|
||||
title: record.title.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -4,19 +4,23 @@ import type { IFilesystemProvider } from '../providers/types'
|
|||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanAiVaultSessionsInWorker: vi.fn(),
|
||||
resolveAiVaultSessionTitlesInWorker: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
scanRuntimeAiVaultSessions: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn(),
|
||||
requestActiveSshAiVaultSessionList: vi.fn(),
|
||||
requestActiveSshAiVaultSessionTitles: 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/session-scanner-worker-spawn', () => ({
|
||||
scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker,
|
||||
resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker,
|
||||
resetAiVaultScannerWorkerForTests: vi.fn()
|
||||
}))
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
|
|
@ -32,7 +36,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
|||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList,
|
||||
requestActiveSshAiVaultSessionTitles: mocks.requestActiveSshAiVaultSessionTitles
|
||||
}))
|
||||
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
|
|
@ -45,18 +50,20 @@ const EMPTY_RESULT: AiVaultListResult = {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
|
||||
mocks.scanAiVaultSessionsInWorker.mockResolvedValue(EMPTY_RESULT)
|
||||
mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
|
||||
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)
|
||||
mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('Agent Session History scan coalescing', () => {
|
||||
it.each([
|
||||
['local', mocks.scanAiVaultSessions],
|
||||
['local', mocks.scanAiVaultSessionsInWorker],
|
||||
['runtime:remote-server', mocks.scanRuntimeAiVaultSessions]
|
||||
] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => {
|
||||
let resolveScan: ((result: AiVaultListResult) => void) | undefined
|
||||
|
|
@ -102,7 +109,7 @@ describe('Agent Session History scan coalescing', () => {
|
|||
const second = _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
await vi.waitFor(() => expect(resolveRuntime).toBeDefined())
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
controller.abort()
|
||||
|
|
@ -163,7 +170,7 @@ describe('Agent Session History scan coalescing', () => {
|
|||
})
|
||||
|
||||
it('still rejects the handler when a scan fails for a non-cancellation reason', async () => {
|
||||
mocks.scanAiVaultSessions.mockRejectedValue(new Error('transcript root is unreadable'))
|
||||
mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable'))
|
||||
registerAiVaultHandlers()
|
||||
const list = ipcHandler('aiVault:listSessions')
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
parseExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
|
||||
import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation'
|
||||
import { requestActiveSshAiVaultSessionTitles } from './ssh'
|
||||
|
||||
export type RuntimeAiVaultSessionTitleResolver = (
|
||||
environmentId: string,
|
||||
args: AiVaultSessionTitlesArgs
|
||||
) => Promise<AiVaultSessionTitlesResult>
|
||||
|
||||
export async function resolveAiVaultSessionTitlesByHost(
|
||||
args: AiVaultSessionTitlesArgs,
|
||||
resolveRuntime?: RuntimeAiVaultSessionTitleResolver
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return resolveLocalAiVaultSessionTitles(args.requests)
|
||||
}
|
||||
const parsed = parseExecutionHostId(executionHostScope)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
try {
|
||||
const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, {
|
||||
requests: args.requests
|
||||
})
|
||||
return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result)
|
||||
} catch {
|
||||
return { titles: [] }
|
||||
}
|
||||
}
|
||||
if (parsed?.kind === 'runtime' && resolveRuntime) {
|
||||
return resolveRuntime(parsed.environmentId, args).catch(() => ({ titles: [] }))
|
||||
}
|
||||
return { titles: [] }
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ 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(),
|
||||
scanAiVaultSessionsInWorker: vi.fn(),
|
||||
resolveAiVaultSessionTitlesInWorker: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
listClaudeSubagentSessions: vi.fn(),
|
||||
listOmpSubagentSessions: vi.fn(),
|
||||
|
|
@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
|||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn(),
|
||||
requestActiveSshAiVaultSessionList: vi.fn(),
|
||||
requestActiveSshAiVaultSessionTitles: vi.fn(),
|
||||
ipcHandle: vi.fn(),
|
||||
deleteAiVaultSessionFile: vi.fn(),
|
||||
invalidateAiVaultSessionListCache: vi.fn(),
|
||||
|
|
@ -30,8 +32,10 @@ vi.mock('electron', () => ({
|
|||
ipcMain: { handle: mocks.ipcHandle }
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({
|
||||
scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker,
|
||||
resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker,
|
||||
resetAiVaultScannerWorkerForTests: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
|
|
@ -83,7 +87,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
|||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
|
||||
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList,
|
||||
requestActiveSshAiVaultSessionTitles: mocks.requestActiveSshAiVaultSessionTitles
|
||||
}))
|
||||
|
||||
const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots')
|
||||
|
|
@ -94,7 +99,8 @@ const provider = {} as IFilesystemProvider
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.scanAiVaultSessionsInWorker.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
|
|
@ -105,6 +111,7 @@ beforeEach(() => {
|
|||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
|
||||
mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(null)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
})
|
||||
|
|
@ -113,11 +120,12 @@ describe('listAiVaultSessions host routing', () => {
|
|||
it('routes local scope to the local scanner', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scopePaths: ['/repo'],
|
||||
executionHostId: 'local'
|
||||
})
|
||||
}),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -128,7 +136,7 @@ describe('listAiVaultSessions host routing', () => {
|
|||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled()
|
||||
expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -287,7 +295,7 @@ describe('listAiVaultSessions host routing', () => {
|
|||
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.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
|
||||
'dev-box',
|
||||
|
|
@ -334,7 +342,7 @@ describe('listAiVaultSessions host routing', () => {
|
|||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
|
|
@ -350,7 +358,7 @@ 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'))
|
||||
mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('session parse cache is corrupt'))
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
|
|
@ -380,7 +388,7 @@ describe('listAiVaultSessions host routing', () => {
|
|||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local'])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
|
|
@ -434,7 +442,7 @@ describe('listAiVaultSessions host routing', () => {
|
|||
await _internals.listAiVaultSessions({ executionHostScope: 'local' })
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
|
@ -496,6 +504,76 @@ describe('listAiVaultSessions host routing', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveAiVaultSessionTitles host routing', () => {
|
||||
const requests = [
|
||||
{ agent: 'codex' as const, sessionId: 'session-1', transcriptPath: '/tmp/session.jsonl' }
|
||||
]
|
||||
const titles = {
|
||||
titles: [{ agent: 'codex' as const, sessionId: 'session-1', title: 'Exact title' }]
|
||||
}
|
||||
|
||||
it('routes local identities to the worker without a broad scan', async () => {
|
||||
mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue(titles)
|
||||
|
||||
await expect(
|
||||
_internals.resolveAiVaultSessionTitles({ executionHostScope: 'local', requests })
|
||||
).resolves.toEqual(titles)
|
||||
|
||||
expect(mocks.resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined)
|
||||
expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes SSH identities to the transcript-owning relay', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(titles)
|
||||
|
||||
await expect(
|
||||
_internals.resolveAiVaultSessionTitles({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
requests
|
||||
})
|
||||
).resolves.toEqual(titles)
|
||||
|
||||
expect(mocks.requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith('dev-box', {
|
||||
requests
|
||||
})
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes runtime identities to the paired runtime host', async () => {
|
||||
const resolveRuntimeAiVaultSessionTitles = vi.fn().mockResolvedValue(titles)
|
||||
registerAiVaultHandlers({ resolveRuntimeAiVaultSessionTitles })
|
||||
|
||||
await expect(
|
||||
_internals.resolveAiVaultSessionTitles({
|
||||
executionHostScope: 'runtime:remote-server',
|
||||
requests
|
||||
})
|
||||
).resolves.toEqual(titles)
|
||||
|
||||
expect(resolveRuntimeAiVaultSessionTitles).toHaveBeenCalledWith('remote-server', {
|
||||
executionHostScope: 'runtime:remote-server',
|
||||
requests
|
||||
})
|
||||
expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades unsupported hosts without falling back to a broad scan', async () => {
|
||||
mocks.requestActiveSshAiVaultSessionTitles.mockRejectedValue(
|
||||
new Error('Method not found: aiVault.resolveSessionTitles')
|
||||
)
|
||||
|
||||
await expect(
|
||||
_internals.resolveAiVaultSessionTitles({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
requests
|
||||
})
|
||||
).resolves.toEqual({ titles: [] })
|
||||
|
||||
expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled()
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSessionResume IPC', () => {
|
||||
it('awaits the host-local targeted resume preparation', async () => {
|
||||
const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true })
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ import {
|
|||
scanHostLegWithCache
|
||||
} from './ai-vault-host-leg-cache'
|
||||
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
|
||||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import {
|
||||
resolveAiVaultSessionTitlesByHost,
|
||||
type RuntimeAiVaultSessionTitleResolver
|
||||
} from './ai-vault-session-title-routing'
|
||||
|
||||
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
|
||||
// Why: a remote home with many agent roots routinely needs seconds to walk,
|
||||
|
|
@ -62,6 +70,7 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
|
|||
AiVaultResumeHandlerOptions & {
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
|
||||
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
|
||||
getSessionLiveness?: Parameters<typeof deleteAiVaultSession>[1]['getSessionLiveness']
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +86,11 @@ const aiVaultDeleteDeps = {
|
|||
) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const)
|
||||
}
|
||||
|
||||
const resolveAiVaultSessionTitles = (
|
||||
args: AiVaultSessionTitlesArgs
|
||||
): Promise<AiVaultSessionTitlesResult> =>
|
||||
resolveAiVaultSessionTitlesByHost(args, handlerOptions.resolveRuntimeAiVaultSessionTitles)
|
||||
|
||||
async function listAiVaultSessions(
|
||||
args?: AiVaultListArgs,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
|
|
@ -279,6 +293,11 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
|||
listCancellations.finish(event, requestToken, controller)
|
||||
}
|
||||
})
|
||||
ipcMain.handle(
|
||||
'aiVault:resolveSessionTitles',
|
||||
(_event, args: AiVaultSessionTitlesArgs): Promise<AiVaultSessionTitlesResult> =>
|
||||
resolveAiVaultSessionTitles(args)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'aiVault:cancelListSessions',
|
||||
(event, args: { requestToken?: string } | undefined): void => {
|
||||
|
|
@ -310,13 +329,13 @@ function resetAiVaultCacheForTests(): void {
|
|||
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.
|
||||
// Keep tests isolated from the shared local-leg cache.
|
||||
resetAiVaultSessionListCacheForTests()
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
listAiVaultSessions,
|
||||
resolveAiVaultSessionTitles,
|
||||
listAiVaultSubagentSessions,
|
||||
deleteAiVaultSession: (args?: AiVaultDeleteSessionArgs) =>
|
||||
deleteAiVaultSession(args, aiVaultDeleteDeps),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import type {
|
|||
import {
|
||||
getSavedRuntimeAiVaultHostInfos,
|
||||
prepareRuntimeAiVaultSessionResume,
|
||||
resolveRuntimeAiVaultSessionTitles,
|
||||
scanRuntimeAiVaultSessions
|
||||
} from '../ai-vault/runtime-session-scanner'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
|
|
@ -222,6 +223,8 @@ export function registerCoreHandlers(
|
|||
getSavedRuntimeAiVaultHostInfos(app.getPath('userData')),
|
||||
scanRuntimeAiVaultSessions: async (environmentId, args, options) =>
|
||||
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options),
|
||||
resolveRuntimeAiVaultSessionTitles: async (environmentId, args) =>
|
||||
resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args),
|
||||
prepareRuntimeSessionResume: async (environmentId, args) =>
|
||||
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args),
|
||||
getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import type { SshConnection, SshConnectionCallbacks } from '../ssh/ssh-connectio
|
|||
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 type {
|
||||
SshAiVaultRelayListParams,
|
||||
SshAiVaultRelayTitleParams
|
||||
} from '../../shared/ssh-ai-vault-relay'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
DetectedPort,
|
||||
|
|
@ -185,6 +188,21 @@ export async function requestActiveSshAiVaultSessionList(
|
|||
return session.requestAiVaultSessionList(params, options)
|
||||
}
|
||||
|
||||
export async function requestActiveSshAiVaultSessionTitles(
|
||||
targetId: string,
|
||||
params: SshAiVaultRelayTitleParams,
|
||||
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.requestAiVaultSessionTitles(params, options)
|
||||
}
|
||||
|
||||
function runTargetLifecycle(targetId: string, operation: () => Promise<void>): Promise<void> {
|
||||
const prior = targetLifecycleInFlight.get(targetId)
|
||||
const operationPromise = (async () => {
|
||||
|
|
|
|||
|
|
@ -489,11 +489,16 @@ import {
|
|||
configureAiVaultSessionSources,
|
||||
listAiVaultSessions
|
||||
} from '../ai-vault/cached-session-list'
|
||||
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
|
||||
import {
|
||||
readAiVaultSessionIdentity,
|
||||
resolveAiVaultSessionLiveness
|
||||
} from '../ai-vault/session-liveness'
|
||||
import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
|
|
@ -4978,6 +4983,13 @@ export class OrcaRuntimeService {
|
|||
return listAiVaultSessions(args)
|
||||
}
|
||||
|
||||
resolveAiVaultSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return resolveLocalAiVaultSessionTitles(requests, signal)
|
||||
}
|
||||
|
||||
async getAiVaultSessionLiveness(target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@ import type { RpcRequest } from '../core'
|
|||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types'
|
||||
import {
|
||||
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
|
||||
RUNTIME_CAPABILITIES
|
||||
} from '../../../../shared/protocol-version'
|
||||
|
||||
const { scanAiVaultSessions } = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn()
|
||||
const { scanAiVaultSessionsInWorker, resolveAiVaultSessionTitlesInWorker } = vi.hoisted(() => ({
|
||||
scanAiVaultSessionsInWorker: vi.fn(),
|
||||
resolveAiVaultSessionTitlesInWorker: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions
|
||||
vi.mock('../../../ai-vault/session-scanner-worker-spawn', () => ({
|
||||
scanAiVaultSessionsInWorker,
|
||||
resolveAiVaultSessionTitlesInWorker,
|
||||
resetAiVaultScannerWorkerForTests: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
|
|
@ -65,11 +72,64 @@ function makeDispatcher(): RpcDispatcher {
|
|||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listAiVaultSessions: (args?: Parameters<typeof listAiVaultSessions>[0]) =>
|
||||
listAiVaultSessions(args)
|
||||
listAiVaultSessions(args),
|
||||
resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) =>
|
||||
resolveAiVaultSessionTitlesInWorker(requests, signal)
|
||||
} as unknown as OrcaRuntimeService
|
||||
return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
|
||||
}
|
||||
|
||||
describe('aiVault.resolveSessionTitles handler', () => {
|
||||
beforeEach(() => {
|
||||
resolveAiVaultSessionTitlesInWorker.mockReset()
|
||||
})
|
||||
|
||||
it('advertises and routes the bounded exact-title capability', async () => {
|
||||
resolveAiVaultSessionTitlesInWorker.mockResolvedValue({
|
||||
titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }]
|
||||
})
|
||||
const dispatcher = makeDispatcher()
|
||||
const requests = [
|
||||
{ agent: 'codex', sessionId: 'session-1', transcriptPath: '/tmp/session.jsonl' }
|
||||
]
|
||||
|
||||
await expect(
|
||||
dispatcher.dispatch(makeRequest('aiVault.resolveSessionTitles', { requests }))
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { titles: [{ sessionId: 'session-1', title: 'Exact title' }] }
|
||||
})
|
||||
expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined)
|
||||
expect(RUNTIME_CAPABILITIES).toContain(AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY)
|
||||
})
|
||||
|
||||
it('forwards transport cancellation to the background scanner', async () => {
|
||||
resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
|
||||
const dispatcher = makeDispatcher()
|
||||
const controller = new AbortController()
|
||||
const requests = [{ agent: 'codex', sessionId: 'session-1' }]
|
||||
|
||||
await dispatcher.dispatch(makeRequest('aiVault.resolveSessionTitles', { requests }), {
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, controller.signal)
|
||||
})
|
||||
|
||||
it('rejects more than 64 title identities before reaching the host', async () => {
|
||||
const dispatcher = makeDispatcher()
|
||||
const requests = Array.from({ length: 65 }, (_, index) => ({
|
||||
agent: 'codex',
|
||||
sessionId: `session-${index}`
|
||||
}))
|
||||
|
||||
await expect(
|
||||
dispatcher.dispatch(makeRequest('aiVault.resolveSessionTitles', { requests }))
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
expect(resolveAiVaultSessionTitlesInWorker).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVault.listSessions params schema', () => {
|
||||
it('accepts a bounded request', () => {
|
||||
const parsed = AiVaultListSessionsParams.safeParse({
|
||||
|
|
@ -153,8 +213,10 @@ describe('aiVault.prepareSessionResume', () => {
|
|||
describe('aiVault.listSessions handler + shared cache', () => {
|
||||
beforeEach(() => {
|
||||
resetAiVaultSessionListCacheForTests()
|
||||
scanAiVaultSessions.mockReset()
|
||||
scanAiVaultSessions.mockResolvedValue(makeResult())
|
||||
scanAiVaultSessionsInWorker.mockReset()
|
||||
scanAiVaultSessionsInWorker.mockResolvedValue(makeResult())
|
||||
resolveAiVaultSessionTitlesInWorker.mockReset()
|
||||
resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -172,7 +234,7 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`)
|
||||
const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { scopePaths }))
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({
|
||||
expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({
|
||||
scopePaths: scopePaths.slice(0, 64)
|
||||
})
|
||||
})
|
||||
|
|
@ -183,7 +245,7 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
await listAiVaultSessions({ limit: 500 })
|
||||
// Second call via the RPC method with the same cache key.
|
||||
await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 }))
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps completed scans cached for one minute', async () => {
|
||||
|
|
@ -192,11 +254,11 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
await listAiVaultSessions({ limit: 500 })
|
||||
await vi.advanceTimersByTimeAsync(59_999)
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(2)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
|
@ -207,14 +269,14 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
await listAiVaultSessions({ limit: 250 })
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessionsInWorker).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)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards Unlimited without a numeric limit', async () => {
|
||||
|
|
@ -224,8 +286,9 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: undefined, unlimited: true })
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: undefined, unlimited: true }),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -233,7 +296,7 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
// Why: the resolving scan's cleanup must not clear tracking a concurrent
|
||||
// different-key scan replaced, or re-requests start a duplicate rescan.
|
||||
const deferreds: ((result: AiVaultListResult) => void)[] = []
|
||||
scanAiVaultSessions.mockImplementation(
|
||||
scanAiVaultSessionsInWorker.mockImplementation(
|
||||
() => new Promise<AiVaultListResult>((resolve) => deferreds.push(resolve))
|
||||
)
|
||||
// The scanner is invoked a microtask after the call (WSL-home await), so
|
||||
|
|
@ -251,13 +314,13 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
// reverted guard reads 3, so this assertion — not a Promise.all hang — pins
|
||||
// the fix.
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(2)
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2)
|
||||
deferreds[1]?.(makeResult())
|
||||
await Promise.all([scanB, scanBAgain])
|
||||
})
|
||||
|
||||
it('restamps the shared cached result as the addressed runtime host', async () => {
|
||||
scanAiVaultSessions.mockResolvedValue({
|
||||
scanAiVaultSessionsInWorker.mockResolvedValue({
|
||||
sessions: [makeSession()],
|
||||
issues: [{ executionHostId: 'local', agent: 'claude', path: '/tmp', message: 'boom' }],
|
||||
scannedAt: SCANNED_AT
|
||||
|
|
@ -277,8 +340,10 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
|
||||
// Why: the host id must never change what is scanned — one host-local scan
|
||||
// (and one cache entry) serves every caller; only the stamps differ.
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({ executionHostId: 'local' })
|
||||
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
|
||||
expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(localResponse.result.sessions[0]?.executionHostId).toBe('local')
|
||||
expect(runtimeResponse.result.sessions[0]?.executionHostId).toBe('runtime:remote-server')
|
||||
|
|
@ -294,7 +359,7 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
})
|
||||
const dispatcher = makeDispatcher()
|
||||
await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
|
||||
const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions
|
||||
const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions
|
||||
// Why: the codex-home is sourced from the runtime, not the window-only
|
||||
// registerCoreHandlers path, so it survives in serve mode.
|
||||
expect(options.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions')
|
||||
|
|
@ -309,7 +374,7 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
|||
getAdditionalAiVaultCodexHomePaths: () => ['/ctor/codex/home']
|
||||
})
|
||||
await runtime.listAiVaultSessions({})
|
||||
const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions
|
||||
const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions
|
||||
expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { defineMethod, type RpcMethod } from '../core'
|
|||
import { OptionalBoolean } from '../schemas'
|
||||
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
|
||||
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types'
|
||||
import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title'
|
||||
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
// Why: bound limit + scopePaths so a client cannot force an unbounded scan.
|
||||
|
|
@ -59,7 +60,25 @@ export const AiVaultPrepareSessionResumeParams = z.object({
|
|||
executionHostId: z.string().optional()
|
||||
})
|
||||
|
||||
export const AiVaultSessionTitlesParams = z.object({
|
||||
requests: z
|
||||
.array(
|
||||
z.object({
|
||||
agent: z.enum(['claude', 'codex']),
|
||||
sessionId: z.string().min(1).max(512),
|
||||
transcriptPath: z.string().min(1).max(32_768).optional()
|
||||
})
|
||||
)
|
||||
.max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
|
||||
})
|
||||
|
||||
export const AI_VAULT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'aiVault.resolveSessionTitles',
|
||||
params: AiVaultSessionTitlesParams,
|
||||
handler: (params, { runtime, signal }) =>
|
||||
runtime.resolveAiVaultSessionTitles(params.requests, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'aiVault.listSessions',
|
||||
params: AiVaultListSessionsParams,
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
|||
'accounts.subscribe',
|
||||
'accounts.unsubscribe',
|
||||
'aiVault.listSessions',
|
||||
'aiVault.resolveSessionTitles',
|
||||
'aiVault.prepareSessionResume',
|
||||
'browser.back',
|
||||
'browser.dialogAccept',
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/executi
|
|||
import {
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS,
|
||||
type SshAiVaultRelayListParams
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD,
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS,
|
||||
type SshAiVaultRelayListParams,
|
||||
type SshAiVaultRelayTitleParams
|
||||
} from '../../shared/ssh-ai-vault-relay'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
|
|
@ -312,6 +315,7 @@ export class SshRelaySession {
|
|||
private hostPlatform: RemoteHostPlatform | null = null
|
||||
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
|
||||
private aiVaultListMethodSupported: boolean | null = null
|
||||
private aiVaultTitleMethodSupported: boolean | null = null
|
||||
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
|
||||
private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget()
|
||||
private activePtyProviderGeneration: number | null = null
|
||||
|
|
@ -458,6 +462,33 @@ export class SshRelaySession {
|
|||
}
|
||||
}
|
||||
|
||||
async requestAiVaultSessionTitles(
|
||||
params: SshAiVaultRelayTitleParams,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
||||
): Promise<unknown | null> {
|
||||
if (this.aiVaultTitleMethodSupported === 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_RESOLVE_SESSION_TITLES_METHOD, params, {
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs ?? SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS
|
||||
})
|
||||
this.aiVaultTitleMethodSupported = true
|
||||
return result
|
||||
} catch (error) {
|
||||
if (isMethodNotFoundError(error)) {
|
||||
this.aiVaultTitleMethodSupported = false
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getPortScanner(): PortScanner | null {
|
||||
return this.portScanner
|
||||
}
|
||||
|
|
@ -477,6 +508,7 @@ export class SshRelaySession {
|
|||
}
|
||||
this._state = 'deploying'
|
||||
this.aiVaultListMethodSupported = null
|
||||
this.aiVaultTitleMethodSupported = null
|
||||
this.currentConnection = conn
|
||||
|
||||
try {
|
||||
|
|
@ -615,6 +647,7 @@ export class SshRelaySession {
|
|||
|
||||
this._state = 'reconnecting'
|
||||
this.aiVaultListMethodSupported = null
|
||||
this.aiVaultTitleMethodSupported = null
|
||||
this.currentConnection = conn
|
||||
|
||||
// Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer.
|
||||
|
|
|
|||
|
|
@ -511,6 +511,10 @@ import type {
|
|||
AiVaultSubagentListArgs,
|
||||
AiVaultSubagentListResult
|
||||
} from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
|
|
@ -919,6 +923,7 @@ export type OpenCodeUsageApi = {
|
|||
|
||||
export type AiVaultApi = {
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult>
|
||||
cancelListSessions: (args: { requestToken: string }) => Promise<void>
|
||||
prepareSessionResume: (
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ import type {
|
|||
AiVaultListArgs,
|
||||
AiVaultSubagentListArgs
|
||||
} from '../shared/ai-vault-types'
|
||||
import type { AiVaultSessionTitlesArgs } from '../shared/ai-vault-session-title'
|
||||
import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation'
|
||||
import type { AgentType } from '../shared/native-chat-types'
|
||||
import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events'
|
||||
|
|
@ -4238,6 +4239,8 @@ const api = {
|
|||
aiVault: {
|
||||
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:listSessions', args),
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
|
||||
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('aiVault:cancelListSessions', args),
|
||||
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise<unknown> =>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ 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 {
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_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'
|
||||
|
|
@ -19,6 +22,49 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('AiVaultHandler', () => {
|
||||
it('resolves an exact transcript title without invoking the broad scanner', async () => {
|
||||
const remoteHome = await makeTemporaryHome()
|
||||
const transcriptPath = join(remoteHome, 'session.jsonl')
|
||||
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: 'Resolve only this transcript' }]
|
||||
}
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
|
||||
requests: [
|
||||
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath },
|
||||
{ agent: 'codex', sessionId: 'other', transcriptPath: '' }
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({
|
||||
titles: [{ agent: 'codex', sessionId: 'ssh-session', title: 'Resolve only this transcript' }]
|
||||
})
|
||||
expect(scanRemoteSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discovers and parses sessions entirely on the relay host', async () => {
|
||||
const remoteHome = await makeTemporaryHome()
|
||||
const transcriptPath = join(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@ 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 {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import {
|
||||
SSH_AI_VAULT_LIST_LIMIT_MAX,
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD,
|
||||
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
|
||||
type SshAiVaultRelayListParams
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
|
|
@ -16,6 +22,7 @@ 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'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
|
||||
type ScanRemoteSessions = typeof scanRemoteAiVaultSessions
|
||||
|
||||
|
|
@ -48,6 +55,16 @@ export class AiVaultHandler {
|
|||
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
|
||||
this.listSessions(hostPlatform, params, context.signal)
|
||||
)
|
||||
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
|
||||
this.resolveSessionTitles(params, context.signal)
|
||||
)
|
||||
}
|
||||
|
||||
private resolveSessionTitles(
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return readAiVaultSessionTitlesFromFiles(normalizeTitleRequests(rawParams.requests), { signal })
|
||||
}
|
||||
|
||||
private async listSessions(
|
||||
|
|
@ -96,6 +113,34 @@ export class AiVaultHandler {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeTitleRequests(raw: unknown): AiVaultSessionTitleRequest[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
const requests: AiVaultSessionTitleRequest[] = []
|
||||
for (const value of raw.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const agent = record.agent
|
||||
const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
|
||||
const transcriptPath =
|
||||
typeof record.transcriptPath === 'string' ? record.transcriptPath.trim() : ''
|
||||
if (
|
||||
(agent !== 'claude' && agent !== 'codex') ||
|
||||
!sessionId ||
|
||||
sessionId.length > 512 ||
|
||||
!transcriptPath ||
|
||||
transcriptPath.length > 32_768
|
||||
) {
|
||||
continue
|
||||
}
|
||||
requests.push({ agent, sessionId, transcriptPath })
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
export function normalizeSshAiVaultRelayListParams(
|
||||
params: Record<string, unknown>
|
||||
): SshAiVaultRelayListParams {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,25 @@
|
|||
import { useEffect } from 'react'
|
||||
import { startAiVaultTabTitleSync } from '@/lib/ai-vault-tab-title-sync'
|
||||
import { scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const TITLE_SYNC_DELAY_MS = 1_000
|
||||
const TITLE_SYNC_QUIET_MS = 1_500
|
||||
const TITLE_SYNC_IDLE_TIMEOUT_MS = 3_000
|
||||
|
||||
export function AiVaultTabTitleSyncGate(): null {
|
||||
useEffect(
|
||||
() =>
|
||||
startAiVaultTabTitleSync({
|
||||
getState: useAppStore.getState,
|
||||
subscribe: useAppStore.subscribe,
|
||||
listSessions: (args) => window.api.aiVault.listSessions(args)
|
||||
resolveSessionTitles: (args) => window.api.aiVault.resolveSessionTitles(args),
|
||||
scheduleReconcile: (callback) =>
|
||||
scheduleAfterInputQuiet(callback, {
|
||||
delayMs: TITLE_SYNC_DELAY_MS,
|
||||
quietMs: TITLE_SYNC_QUIET_MS,
|
||||
idleTimeoutMs: TITLE_SYNC_IDLE_TIMEOUT_MS
|
||||
})
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../shared/ai-vault-session-title'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { AiVaultTitleRequest } from './ai-vault-tab-title-requests'
|
||||
|
||||
export function batchAiVaultTitleRequests(
|
||||
requests: AiVaultTitleRequest[]
|
||||
): AiVaultTitleRequest[][] {
|
||||
const byHost = new Map<ExecutionHostId, AiVaultTitleRequest[]>()
|
||||
for (const request of requests) {
|
||||
const hostRequests = byHost.get(request.executionHostId)
|
||||
if (hostRequests) {
|
||||
hostRequests.push(request)
|
||||
} else {
|
||||
byHost.set(request.executionHostId, [request])
|
||||
}
|
||||
}
|
||||
const batches: AiVaultTitleRequest[][] = []
|
||||
for (const hostRequests of byHost.values()) {
|
||||
for (
|
||||
let index = 0;
|
||||
index < hostRequests.length;
|
||||
index += AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT
|
||||
) {
|
||||
batches.push(hostRequests.slice(index, index + AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT))
|
||||
}
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
export async function settleAiVaultTitleRequestBatches(
|
||||
requests: AiVaultTitleRequest[],
|
||||
resolveBatch: (batch: AiVaultTitleRequest[]) => Promise<void>
|
||||
): Promise<void> {
|
||||
const batchesByHost = new Map<ExecutionHostId, AiVaultTitleRequest[][]>()
|
||||
for (const batch of batchAiVaultTitleRequests(requests)) {
|
||||
const executionHostId = batch[0]!.executionHostId
|
||||
const hostBatches = batchesByHost.get(executionHostId) ?? []
|
||||
hostBatches.push(batch)
|
||||
batchesByHost.set(executionHostId, hostBatches)
|
||||
}
|
||||
await Promise.all(
|
||||
[...batchesByHost.values()].map(async (hostBatches) => {
|
||||
for (const batch of hostBatches) {
|
||||
try {
|
||||
await resolveBatch(batch)
|
||||
} catch {
|
||||
// One unavailable host/batch must not suppress later exact identities.
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ export type AiVaultTitleRequest = {
|
|||
executionHostId: ExecutionHostId
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
refresh: boolean
|
||||
scopePath: string | null
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
|
@ -57,13 +56,11 @@ function registerCandidate(
|
|||
return
|
||||
}
|
||||
const executionHostId = getExecutionHostIdForWorktree(state, worktreeId)
|
||||
const scopePath = state.getKnownWorktreeById(worktreeId, executionHostId)?.path?.trim() || null
|
||||
candidates.set(tabId, {
|
||||
agent: args.agent,
|
||||
executionHostId,
|
||||
providerSession: args.providerSession,
|
||||
refresh: args.refresh,
|
||||
scopePath,
|
||||
tabId,
|
||||
worktreeId,
|
||||
priority
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { AiVaultTitleRequest } from './ai-vault-tab-title-requests'
|
||||
|
||||
export function groupAiVaultTitleRequests(
|
||||
requests: AiVaultTitleRequest[]
|
||||
): AiVaultTitleRequest[][] {
|
||||
const byHost = new Map<ExecutionHostId, Map<string | null, AiVaultTitleRequest[]>>()
|
||||
for (const request of requests) {
|
||||
const byPath = byHost.get(request.executionHostId) ?? new Map()
|
||||
const pathRequests = byPath.get(request.scopePath)
|
||||
if (pathRequests) {
|
||||
pathRequests.push(request)
|
||||
} else {
|
||||
byPath.set(request.scopePath, [request])
|
||||
}
|
||||
byHost.set(request.executionHostId, byPath)
|
||||
}
|
||||
|
||||
const groups: AiVaultTitleRequest[][] = []
|
||||
for (const byPath of byHost.values()) {
|
||||
const unscoped = byPath.get(null) ?? []
|
||||
const scoped = [...byPath].filter(
|
||||
(entry): entry is [string, AiVaultTitleRequest[]] => entry[0] !== null
|
||||
)
|
||||
if (scoped.length === 0) {
|
||||
if (unscoped.length > 0) {
|
||||
groups.push(unscoped)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for (let index = 0; index < scoped.length; index += AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
|
||||
groups.push([
|
||||
...(index === 0 ? unscoped : []),
|
||||
...scoped
|
||||
.slice(index, index + AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
.flatMap(([, pathRequests]) => pathRequests)
|
||||
])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
|
@ -9,7 +9,11 @@ function providerSessionEqual(
|
|||
left: AgentProviderSessionMetadata | undefined,
|
||||
right: AgentProviderSessionMetadata | undefined
|
||||
): boolean {
|
||||
return left?.key === right?.key && left?.id === right?.id
|
||||
return (
|
||||
left?.key === right?.key &&
|
||||
left?.id === right?.id &&
|
||||
left?.transcriptPath === right?.transcriptPath
|
||||
)
|
||||
}
|
||||
|
||||
function relevantRecordEqual<T>(
|
||||
|
|
@ -150,12 +154,6 @@ function requestOwnersEqual(current: AppState, previous: AppState): boolean {
|
|||
if (currentHost !== previousHost) {
|
||||
return false
|
||||
}
|
||||
const currentPath = current.getKnownWorktreeById(worktreeId, currentHost)?.path?.trim() || null
|
||||
const previousPath =
|
||||
previous.getKnownWorktreeById(worktreeId, previousHost)?.path?.trim() || null
|
||||
if (currentPath !== previousPath) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultListResult,
|
||||
AiVaultSession
|
||||
} from '../../../shared/ai-vault-types'
|
||||
import type { AiVaultSessionTitlesResult } from '../../../shared/ai-vault-session-title'
|
||||
import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution'
|
||||
import type { TerminalTab } from '../../../shared/types'
|
||||
import {
|
||||
collectAiVaultTitleRequests,
|
||||
type AiVaultTitleRequest
|
||||
} from './ai-vault-tab-title-requests'
|
||||
import { groupAiVaultTitleRequests } from './ai-vault-tab-title-scan-groups'
|
||||
import {
|
||||
batchAiVaultTitleRequests,
|
||||
settleAiVaultTitleRequestBatches
|
||||
} from './ai-vault-tab-title-batches'
|
||||
import { startAiVaultTabTitleSync } from './ai-vault-tab-title-sync'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
|
|
@ -28,33 +27,8 @@ function terminalTab(worktreeId: string, aiVaultTitle?: TerminalTab['aiVaultTitl
|
|||
}
|
||||
}
|
||||
|
||||
function session(
|
||||
agent: Extract<AiVaultAgent, 'claude' | 'codex'>,
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
title: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:${agent}:${agent}-session:/sessions/${agent}.jsonl`,
|
||||
executionHostId,
|
||||
agent,
|
||||
sessionId: `${agent}-session`,
|
||||
title,
|
||||
cwd: '/workspace/albacore',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${agent}.jsonl`,
|
||||
codexHome: agent === 'codex' ? '/home/dev/.codex' : null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-08-05T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `${agent} --resume ${agent}-session`,
|
||||
subagent: null
|
||||
}
|
||||
function titleResult(agent: 'claude' | 'codex', title: string): AiVaultSessionTitlesResult {
|
||||
return { titles: [{ agent, sessionId: `${agent}-session`, title }] }
|
||||
}
|
||||
|
||||
function makeState(args: {
|
||||
|
|
@ -68,7 +42,11 @@ function makeState(args: {
|
|||
const agent = args.agent ?? 'codex'
|
||||
const tab = terminalTab(args.worktreeId, args.aiVaultTitle)
|
||||
const listeners = new Set<(state: AppState, previous: AppState) => void>()
|
||||
const providerSession = { key: 'session_id' as const, id: `${agent}-session` }
|
||||
const providerSession = {
|
||||
key: 'session_id' as const,
|
||||
id: `${agent}-session`,
|
||||
transcriptPath: `/sessions/${agent}.jsonl`
|
||||
}
|
||||
const statusEntry = {
|
||||
state: 'done' as const,
|
||||
prompt: '',
|
||||
|
|
@ -166,6 +144,14 @@ function makeState(args: {
|
|||
listener(state, previous)
|
||||
}
|
||||
},
|
||||
setWorkspacePath: (path: string) => {
|
||||
args.path = path
|
||||
const previous = state
|
||||
state = { ...state, worktreesByRepo: { changed: [] } }
|
||||
for (const listener of listeners) {
|
||||
listener(state, previous)
|
||||
}
|
||||
},
|
||||
removeSleepingRecord: () => {
|
||||
const previous = state
|
||||
state = { ...state, sleepingAgentSessionsByPaneKey: {} }
|
||||
|
|
@ -190,14 +176,8 @@ describe('AI Vault tab title sync', () => {
|
|||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const listSessions = vi.fn(
|
||||
async (): Promise<AiVaultListResult> => ({
|
||||
sessions: [session(agent, 'ssh:dev-box', `${agent} conversation`)],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-05T00:00:00.000Z'
|
||||
})
|
||||
)
|
||||
const stop = startAiVaultTabTitleSync({ ...store, listSessions })
|
||||
const resolveSessionTitles = vi.fn(async () => titleResult(agent, `${agent} conversation`))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toEqual({
|
||||
|
|
@ -206,16 +186,21 @@ describe('AI Vault tab title sync', () => {
|
|||
title: `${agent} conversation`
|
||||
})
|
||||
)
|
||||
expect(listSessions).toHaveBeenCalledWith({
|
||||
expect(resolveSessionTitles).toHaveBeenCalledWith({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/workspace/albacore'],
|
||||
limit: 500
|
||||
requests: [
|
||||
{
|
||||
agent,
|
||||
sessionId: `${agent}-session`,
|
||||
transcriptPath: `/sessions/${agent}.jsonl`
|
||||
}
|
||||
]
|
||||
})
|
||||
stop()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses runtime host authority and folder workspace paths', () => {
|
||||
it('uses runtime host authority for folder workspaces', () => {
|
||||
const store = makeState({
|
||||
executionHostId: 'runtime:server-1',
|
||||
worktreeId: 'folder:folder-1',
|
||||
|
|
@ -225,7 +210,6 @@ describe('AI Vault tab title sync', () => {
|
|||
expect(collectAiVaultTitleRequests(store.getState())).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'runtime:server-1',
|
||||
scopePath: '/srv/folders/albacore',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'folder:folder-1'
|
||||
})
|
||||
|
|
@ -241,11 +225,7 @@ describe('AI Vault tab title sync', () => {
|
|||
})
|
||||
const stop = startAiVaultTabTitleSync({
|
||||
...store,
|
||||
listSessions: async () => ({
|
||||
sessions: [session('codex', 'ssh:dev-box', 'Stable conversation')],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-05T00:00:00.000Z'
|
||||
})
|
||||
resolveSessionTitles: async () => titleResult('codex', 'Stable conversation')
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
|
|
@ -268,12 +248,9 @@ describe('AI Vault tab title sync', () => {
|
|||
let refresh: (() => void) | undefined
|
||||
const stop = startAiVaultTabTitleSync({
|
||||
...store,
|
||||
listSessions: async () => ({
|
||||
sessions: [session('codex', 'ssh:dev-box', title)],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-05T00:00:00.000Z'
|
||||
}),
|
||||
setTimer: (callback) => {
|
||||
resolveSessionTitles: async () => titleResult('codex', title),
|
||||
setTimer: (callback, delay) => {
|
||||
expect(delay).toBe(5 * 60_000)
|
||||
refresh = callback
|
||||
return 1
|
||||
},
|
||||
|
|
@ -291,25 +268,84 @@ describe('AI Vault tab title sync', () => {
|
|||
stop()
|
||||
})
|
||||
|
||||
it('does not rescan when a live status ping preserves title inputs', async () => {
|
||||
it('retries a missing live title without waiting for the long refresh', async () => {
|
||||
const store = makeState({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const listSessions = vi.fn(async () => ({
|
||||
sessions: [session('codex', 'ssh:dev-box', 'Stable conversation')],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-05T00:00:00.000Z'
|
||||
}))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, listSessions })
|
||||
let refreshDelay: number | undefined
|
||||
const stop = startAiVaultTabTitleSync({
|
||||
...store,
|
||||
resolveSessionTitles: async () => ({ titles: [] }),
|
||||
setTimer: (_callback, delay) => {
|
||||
refreshDelay = delay
|
||||
return 1
|
||||
},
|
||||
clearTimer: () => {}
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(refreshDelay).toBe(20_000))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('defers title reads through the configured background scheduler', async () => {
|
||||
const store = makeState({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'Deferred conversation'))
|
||||
let runScheduled: (() => void) | undefined
|
||||
const cancelScheduled = vi.fn()
|
||||
const stop = startAiVaultTabTitleSync({
|
||||
...store,
|
||||
resolveSessionTitles,
|
||||
scheduleReconcile: (callback) => {
|
||||
runScheduled = callback
|
||||
return cancelScheduled
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolveSessionTitles).not.toHaveBeenCalled()
|
||||
runScheduled?.()
|
||||
await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not reread when a live status ping preserves title inputs', async () => {
|
||||
const store = makeState({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'Stable conversation'))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
|
||||
|
||||
await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1))
|
||||
store.pingAgentStatus()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(listSessions).toHaveBeenCalledTimes(1)
|
||||
expect(resolveSessionTitles).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not reread titles when only the worktree path changes', async () => {
|
||||
const store = makeState({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'Stable conversation'))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
|
||||
|
||||
await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1))
|
||||
store.setWorkspacePath('/workspace/renamed-albacore')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(resolveSessionTitles).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
|
|
@ -319,32 +355,27 @@ describe('AI Vault tab title sync', () => {
|
|||
worktreeId: 'worktree-1',
|
||||
path: '/workspace/albacore'
|
||||
})
|
||||
const listSessions = vi.fn(async () => ({
|
||||
sessions: [session('codex', 'ssh:dev-box', 'Original conversation')],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-05T00:00:00.000Z'
|
||||
}))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, listSessions })
|
||||
const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'Original conversation'))
|
||||
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
|
||||
|
||||
await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1))
|
||||
store.setProviderSessionId('codex-session-2')
|
||||
|
||||
await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(2))
|
||||
expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toBeNull()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('batches workspace scopes per host within the wire bound', () => {
|
||||
it('batches title identities per host within the wire bound', () => {
|
||||
const request = (index: number): AiVaultTitleRequest => ({
|
||||
agent: 'codex',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
providerSession: { key: 'session_id', id: `session-${index}` },
|
||||
refresh: true,
|
||||
scopePath: `/workspace/project-${index}`,
|
||||
tabId: `tab-${index}`,
|
||||
worktreeId: `worktree-${index}`
|
||||
})
|
||||
const groups = groupAiVaultTitleRequests(
|
||||
const groups = batchAiVaultTitleRequests(
|
||||
Array.from({ length: 65 }, (_, index) => request(index))
|
||||
)
|
||||
|
||||
|
|
@ -352,4 +383,41 @@ describe('AI Vault tab title sync', () => {
|
|||
expect(groups[0]).toHaveLength(64)
|
||||
expect(groups[1]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs hosts concurrently while serializing each host wire', async () => {
|
||||
const request = (executionHostId: AiVaultTitleRequest['executionHostId'], index: number) => ({
|
||||
agent: 'codex' as const,
|
||||
executionHostId,
|
||||
providerSession: { key: 'session_id' as const, id: `session-${index}` },
|
||||
refresh: true,
|
||||
tabId: `tab-${index}`,
|
||||
worktreeId: `worktree-${index}`
|
||||
})
|
||||
const requests = [
|
||||
...Array.from({ length: 65 }, (_, index) => request('ssh:dev-box', index)),
|
||||
request('runtime:server-1', 100)
|
||||
]
|
||||
const calls: AiVaultTitleRequest[][] = []
|
||||
const completions: (() => void)[] = []
|
||||
const pending = settleAiVaultTitleRequestBatches(
|
||||
requests,
|
||||
(batch) =>
|
||||
new Promise<void>((resolve) => {
|
||||
calls.push(batch)
|
||||
completions.push(resolve)
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(2))
|
||||
expect(calls.map((batch) => batch[0]!.executionHostId)).toEqual([
|
||||
'ssh:dev-box',
|
||||
'runtime:server-1'
|
||||
])
|
||||
completions[0]!()
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(3))
|
||||
expect(calls[2]).toHaveLength(1)
|
||||
completions[1]!()
|
||||
completions[2]!()
|
||||
await pending
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types'
|
||||
import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title'
|
||||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../../shared/ai-vault-session-title'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
collectAiVaultTitleRequests,
|
||||
type AiVaultTitleRequest
|
||||
} from './ai-vault-tab-title-requests'
|
||||
import { groupAiVaultTitleRequests } from './ai-vault-tab-title-scan-groups'
|
||||
import { settleAiVaultTitleRequestBatches } from './ai-vault-tab-title-batches'
|
||||
import { aiVaultTitleSyncInputsChanged } from './ai-vault-tab-title-sync-inputs'
|
||||
|
||||
const LIVE_TITLE_REFRESH_MS = 20_000
|
||||
const MISSING_TITLE_REFRESH_MS = 20_000
|
||||
const LIVE_TITLE_REFRESH_MS = 5 * 60_000
|
||||
|
||||
function requestIdentity(request: AiVaultTitleRequest): string {
|
||||
return `${request.executionHostId}\0${request.agent}\0${request.providerSession.id}`
|
||||
|
|
@ -16,12 +19,46 @@ function requestIdentity(request: AiVaultTitleRequest): string {
|
|||
|
||||
type SyncDependencies = {
|
||||
getState: () => AppState
|
||||
listSessions: (args: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult>
|
||||
subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void
|
||||
scheduleReconcile?: (callback: () => void) => () => void
|
||||
setTimer?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> | number
|
||||
clearTimer?: (timer: ReturnType<typeof setTimeout> | number) => void
|
||||
}
|
||||
|
||||
function scheduleMicrotask(callback: () => void): () => void {
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (!cancelled) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
function nextLiveRefreshDelay(state: AppState, requests: AiVaultTitleRequest[]): number | null {
|
||||
const liveRequests = requests.filter((request) => request.refresh)
|
||||
if (liveRequests.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tabsById = new Map(
|
||||
Object.values(state.tabsByWorktree)
|
||||
.flat()
|
||||
.map((tab) => [tab.id, tab] as const)
|
||||
)
|
||||
const hasMissingTitle = liveRequests.some((request) => {
|
||||
const stored = tabsById.get(request.tabId)?.aiVaultTitle
|
||||
return (
|
||||
stored?.agent !== request.agent ||
|
||||
stored.sessionId !== request.providerSession.id ||
|
||||
!stored.title.trim()
|
||||
)
|
||||
})
|
||||
return hasMissingTitle ? MISSING_TITLE_REFRESH_MS : LIVE_TITLE_REFRESH_MS
|
||||
}
|
||||
|
||||
export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => void {
|
||||
const setTimer = dependencies.setTimer ?? setTimeout
|
||||
const clearTimer =
|
||||
|
|
@ -32,6 +69,7 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
|
|||
let scanInFlight = false
|
||||
let scanAgain = false
|
||||
let scheduled = false
|
||||
let cancelScheduled: (() => void) | null = null
|
||||
let stopped = false
|
||||
let writing = false
|
||||
|
||||
|
|
@ -49,23 +87,27 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
|
|||
}
|
||||
}
|
||||
|
||||
const scanGroup = async (requests: AiVaultTitleRequest[]): Promise<void> => {
|
||||
const resolveBatch = async (requests: AiVaultTitleRequest[]): Promise<void> => {
|
||||
const first = requests[0]!
|
||||
const scopePaths = [...new Set(requests.flatMap((request) => request.scopePath ?? []))]
|
||||
const result = await dependencies.listSessions({
|
||||
const result = await dependencies.resolveSessionTitles({
|
||||
executionHostScope: first.executionHostId,
|
||||
...(scopePaths.length > 0 ? { scopePaths } : {}),
|
||||
limit: 500
|
||||
requests: requests.map((request) => ({
|
||||
agent: request.agent,
|
||||
sessionId: request.providerSession.id,
|
||||
...(request.providerSession.transcriptPath
|
||||
? { transcriptPath: request.providerSession.transcriptPath }
|
||||
: {})
|
||||
}))
|
||||
})
|
||||
if (stopped || result.cancelled) {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
const titleByIdentity = new Map<string, string>()
|
||||
for (const session of result.sessions) {
|
||||
if (isAiVaultTitleAgent(session.agent) && session.title.trim()) {
|
||||
for (const title of result.titles) {
|
||||
if (title.title.trim()) {
|
||||
titleByIdentity.set(
|
||||
`${session.executionHostId}\0${session.agent}\0${session.sessionId}`,
|
||||
session.title.trim()
|
||||
`${first.executionHostId}\0${title.agent}\0${title.sessionId}`,
|
||||
title.title.trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -117,15 +159,20 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
|
|||
|
||||
if (requestsToScan.length > 0) {
|
||||
scanInFlight = true
|
||||
await Promise.allSettled(groupAiVaultTitleRequests(requestsToScan).map(scanGroup))
|
||||
await settleAiVaultTitleRequestBatches(requestsToScan, resolveBatch)
|
||||
scanInFlight = false
|
||||
}
|
||||
|
||||
if (scanAgain) {
|
||||
scanAgain = false
|
||||
schedule()
|
||||
} else if (!stopped && requests.some((request) => request.refresh)) {
|
||||
refreshTimer = setTimer(schedule, LIVE_TITLE_REFRESH_MS)
|
||||
} else if (!stopped) {
|
||||
const currentState = dependencies.getState()
|
||||
const currentRequests = collectAiVaultTitleRequests(currentState)
|
||||
const refreshDelay = nextLiveRefreshDelay(currentState, currentRequests)
|
||||
if (refreshDelay !== null) {
|
||||
refreshTimer = setTimer(schedule, refreshDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +181,10 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
|
|||
return
|
||||
}
|
||||
scheduled = true
|
||||
queueMicrotask(() => void reconcile())
|
||||
cancelScheduled = (dependencies.scheduleReconcile ?? scheduleMicrotask)(() => {
|
||||
cancelScheduled = null
|
||||
void reconcile()
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = dependencies.subscribe((state, previous) => {
|
||||
|
|
@ -147,6 +197,8 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
|
|||
return () => {
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
cancelScheduled?.()
|
||||
cancelScheduled = null
|
||||
if (refreshTimer !== null) {
|
||||
clearTimer(refreshTimer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { parseHostAccessLink } from '../../../shared/remote-pairing-address'
|
|||
import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification'
|
||||
import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitlesArgs,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../../shared/ai-vault-session-title'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
|
|
@ -1533,6 +1537,19 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
|
|||
executionHostId
|
||||
})
|
||||
},
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => {
|
||||
const environment = requireActiveEnvironment()
|
||||
const executionHostId = toRuntimeExecutionHostId(environment.id)
|
||||
if (
|
||||
args.executionHostScope &&
|
||||
normalizeExecutionHostScope(args.executionHostScope) !== executionHostId
|
||||
) {
|
||||
return Promise.resolve({ titles: [] })
|
||||
}
|
||||
return callRuntimeResult<AiVaultSessionTitlesResult>('aiVault.resolveSessionTitles', {
|
||||
requests: args.requests
|
||||
}).catch(() => ({ titles: [] }))
|
||||
},
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { AiVaultAgent } from './ai-vault-types'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
|
||||
export const AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT = 64
|
||||
|
||||
export type AiVaultSessionTitle = {
|
||||
agent: Extract<AiVaultAgent, 'claude' | 'codex'>
|
||||
|
|
@ -6,6 +9,21 @@ export type AiVaultSessionTitle = {
|
|||
title: string
|
||||
}
|
||||
|
||||
export type AiVaultSessionTitleRequest = {
|
||||
agent: AiVaultSessionTitle['agent']
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
export type AiVaultSessionTitlesArgs = {
|
||||
executionHostScope?: ExecutionHostId
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
|
||||
export type AiVaultSessionTitlesResult = {
|
||||
titles: AiVaultSessionTitle[]
|
||||
}
|
||||
|
||||
export function isAiVaultTitleAgent(
|
||||
agent: string | null | undefined
|
||||
): agent is AiVaultSessionTitle['agent'] {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY =
|
|||
// STATIC capability advertised by getStatus() automatically — NOT a runtime
|
||||
// conditional like browser.headless.v1.
|
||||
export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const
|
||||
export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const
|
||||
// Why: signals a host owns browser pages with no renderer (headless serve via the
|
||||
// offscreen backend). Advertised only when that backend is actually available, so
|
||||
// clients never fall back to a local desktop browser tab for a remote-owned page.
|
||||
|
|
@ -104,6 +105,7 @@ export const RUNTIME_CAPABILITIES = [
|
|||
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
|
||||
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const
|
||||
export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD = 'aiVault.resolveSessionTitles' as const
|
||||
export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000
|
||||
export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS = 15_000
|
||||
export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
|
||||
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
|
||||
|
||||
|
|
@ -10,3 +12,11 @@ export type SshAiVaultRelayListParams = {
|
|||
scopePaths?: string[]
|
||||
scopePathsTruncated?: boolean
|
||||
}
|
||||
|
||||
export type SshAiVaultRelayTitleParams = {
|
||||
requests: {
|
||||
agent: 'claude' | 'codex'
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
}[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue