mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(ai-vault): make SSH session history host-aware (#7367)
* fix(ai-vault): scan sessions by execution host * fix(ai-vault): route history resume by host * test(e2e): cover SSH AI Vault history * Generalize remote session scanning for all AI Vault agents Replace the Codex-only remote SSH session history scanner with a unified scanner supporting all registered agents. This ensures remote transcripts for Claude, Gemini, Devin, Droid, and others are scanned and listed alongside local history. - Propagate host metadata (host ID and platform) to scanned sessions - Scope remote actions by host, disabling local OS path actions on remote session logs - Resolve ambiguous project/worktree matching for overlapping paths by verifying matching host setup IDs - Update tests and E2E specs to validate multi-agent remote scanning --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
co-authored by
Jinjing
parent
331a30b454
commit
e94c83d164
@@ -0,0 +1,51 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: IFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: IFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
||||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseClaudeSessionContent,
|
||||
parseGeminiSessionContent
|
||||
} from './session-scanner-primary-parsers'
|
||||
import {
|
||||
parseCopilotSessionContent,
|
||||
parseCursorSessionContent,
|
||||
parseHermesSessionContent
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
type RemoteContentParser = (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.gemini', 'tmp'],
|
||||
['.json', '.jsonl'],
|
||||
parseGeminiSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'copilot',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.copilot', 'session-state'],
|
||||
parseCopilotSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'cursor',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.cursor', 'projects'],
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.hermes', 'sessions'],
|
||||
['.json'],
|
||||
parseHermesSessionContent
|
||||
),
|
||||
source(
|
||||
'devin',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.local', 'share', 'devin', 'cli', 'transcripts'],
|
||||
['.json'],
|
||||
parseDevinSessionContent
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'sessions'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'projects'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
...remoteOpenClawSources(remoteHome, hostPlatform)
|
||||
]
|
||||
}
|
||||
|
||||
function source(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
extensions: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent,
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
|
||||
extensions,
|
||||
filePredicate,
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
|
||||
}
|
||||
}
|
||||
|
||||
function jsonlSource(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate)
|
||||
}
|
||||
|
||||
function remoteCodexSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
joinRemotePath(hostPlatform, remoteHome, '.codex'),
|
||||
joinRemotePath(
|
||||
hostPlatform,
|
||||
remoteHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
].map((codexHome) => ({
|
||||
agent: 'codex',
|
||||
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
|
||||
extensions: ['.jsonl'],
|
||||
parse: (file, content, context) =>
|
||||
parseCodexSessionContent({
|
||||
file,
|
||||
content,
|
||||
platform: context.hostPlatform.os,
|
||||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
readIndexedTitle: async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform,
|
||||
titleCaches: context.titleCaches
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function remoteOpenClawSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return ['.openclaw', '.clawdbot'].map((rootName) =>
|
||||
jsonlSource(
|
||||
'openclaw',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
[rootName, 'agents'],
|
||||
openClawParser,
|
||||
(path) => remotePathSegments(path).includes('sessions')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
|
||||
return {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
}
|
||||
|
||||
function piParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
|
||||
export type RemoteScannerContext = {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}
|
||||
|
||||
export type RemoteParserOptions = {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type RemoteSessionSource = {
|
||||
agent: AiVaultAgent
|
||||
rootDir: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
parse: (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
context: RemoteScannerContext
|
||||
) => Promise<AiVaultSession | null>
|
||||
}
|
||||
|
||||
export type RemoteSessionCandidate = {
|
||||
source: RemoteSessionSource
|
||||
file: FileWithMtime
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DirEntry } from '../../shared/types'
|
||||
import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
|
||||
class MemoryRemoteProvider implements IFilesystemProvider {
|
||||
private readonly files = new Map<string, { content: string; mtimeMs: number }>()
|
||||
|
||||
addFile(path: string, content: string, mtimeMs: number): void {
|
||||
this.files.set(normalize(path), { content, mtimeMs })
|
||||
}
|
||||
|
||||
async readDir(dirPath: string): Promise<DirEntry[]> {
|
||||
const dir = normalize(dirPath)
|
||||
const prefix = dir.endsWith('/') ? dir : `${dir}/`
|
||||
const entries = new Map<string, DirEntry>()
|
||||
for (const path of this.files.keys()) {
|
||||
if (!path.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const relative = path.slice(prefix.length)
|
||||
if (!relative) {
|
||||
continue
|
||||
}
|
||||
const [name, ...rest] = relative.split('/')
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
entries.set(name, {
|
||||
name,
|
||||
isDirectory: rest.length > 0,
|
||||
isSymlink: false
|
||||
})
|
||||
}
|
||||
return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<FileReadResult> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { content: file.content, isBinary: false }
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs }
|
||||
}
|
||||
|
||||
writeFile = unsupported
|
||||
writeFileBase64 = unsupported
|
||||
writeFileBase64Chunk = unsupported
|
||||
deletePath = unsupported
|
||||
createFile = unsupported
|
||||
createDir = unsupported
|
||||
createDirNoClobber = unsupported
|
||||
rename = unsupported
|
||||
renameNoClobber = unsupported
|
||||
copy = unsupported
|
||||
realpath = async (path: string): Promise<string> => path
|
||||
search = unsupported
|
||||
listFiles = unsupported
|
||||
watch = unsupported
|
||||
}
|
||||
|
||||
async function unsupported(): Promise<never> {
|
||||
throw new Error('unsupported')
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
|
||||
1
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'default-session', cwd: '/home/ada/repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fallback default title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Managed remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
20
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Managed remote title',
|
||||
'Indexed remote title'
|
||||
])
|
||||
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
|
||||
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
|
||||
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'default-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.codex',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
|
||||
})
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'runtime-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
|
||||
})
|
||||
})
|
||||
|
||||
it('parses non-Codex transcripts through the same remote scanner', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
|
||||
},
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { model: 'claude-opus-4', content: 'Sure.' }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
title: 'Summarize the remote branch',
|
||||
model: 'claude-opus-4',
|
||||
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'C:/Users/Ada/.codex/sessions/win.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'win-session', cwd: 'C:/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Windows remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
|
||||
expect(result.sessions[0]?.resumeCommand).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
|
||||
)
|
||||
})
|
||||
|
||||
it('continues past skipped candidates to fill the remote scan limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/worker.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'worker-session',
|
||||
title: 'Internal worker',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
threadSource: 'agent'
|
||||
}),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/user.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'user-session',
|
||||
title: 'Visible user session',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T03:00:00.000Z'
|
||||
}),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
|
||||
})
|
||||
|
||||
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo/app',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function codexTranscript(args: {
|
||||
sessionId: string
|
||||
title: string
|
||||
cwd: string
|
||||
timestamp: string
|
||||
threadSource?: string
|
||||
}): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp: args.timestamp,
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: args.sessionId,
|
||||
cwd: args.cwd,
|
||||
...(args.threadSource ? { thread_source: args.threadSource } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: args.title }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
|
||||
const REMOTE_SCAN_CONCURRENCY = 8
|
||||
const REMOTE_SCOPE_PARSE_LIMIT = 2000
|
||||
|
||||
export async function scanRemoteAiVaultSessions(args: {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
limit?: number
|
||||
scopePaths?: readonly string[]
|
||||
}): Promise<AiVaultListResult> {
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const context: RemoteScannerContext = {
|
||||
provider: args.provider,
|
||||
executionHostId: args.executionHostId,
|
||||
hostPlatform: args.hostPlatform,
|
||||
titleCaches: new Map()
|
||||
}
|
||||
const candidates = (
|
||||
await mapRemoteScanConcurrently(
|
||||
remoteSessionSources(args.remoteHome, args.hostPlatform),
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues })
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
|
||||
|
||||
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
|
||||
const cappedSessions = parsed.sessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
|
||||
const parsedScopeSessions = parsed.sessions.filter((session) =>
|
||||
isRemoteSessionInScope(session, scopePaths)
|
||||
)
|
||||
const extraScopeSessions = await scanRemoteInScopeSessions({
|
||||
candidates,
|
||||
context,
|
||||
issues,
|
||||
scopePaths,
|
||||
alreadyParsedFilePaths: parsed.parsedFilePaths
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRemoteSourceCandidates(args: {
|
||||
source: RemoteSessionSource
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
const paths = await walkRemoteSessionFiles(
|
||||
args.source,
|
||||
args.context.provider,
|
||||
args.context.hostPlatform
|
||||
)
|
||||
const files = await mapRemoteScanConcurrently(paths, (path) =>
|
||||
statRemoteFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues
|
||||
)
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({ source: args.source, file }))
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
source: RemoteSessionSource,
|
||||
provider: IFilesystemProvider,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
dirPath = source.rootDir
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await provider.readDir(dirPath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
|
||||
if (entry.isDirectory && !entry.isSymlink) {
|
||||
files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath)))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!entry.isSymlink &&
|
||||
extensions.has(extname(entry.name).toLowerCase()) &&
|
||||
(source.filePredicate?.(fullPath) ?? true)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidates(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
limit: number
|
||||
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
const parsedFilePaths = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
|
||||
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
for (const candidate of batch) {
|
||||
parsedFilePaths.add(candidate.file.path)
|
||||
}
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(...results.filter(isAiVaultSession))
|
||||
index += batch.length
|
||||
}
|
||||
|
||||
return { sessions, parsedFilePaths }
|
||||
}
|
||||
|
||||
async function scanRemoteInScopeSessions(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = args.candidates
|
||||
.filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
|
||||
.slice(0, REMOTE_SCOPE_PARSE_LIMIT)
|
||||
const sessions: AiVaultSession[] = []
|
||||
|
||||
for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(
|
||||
...results.filter(
|
||||
(session): session is AiVaultSession =>
|
||||
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidate(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
const read = await context.provider.readFile(candidate.file.path)
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return candidate.source.parse(candidate.file, read.content, context)
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRemoteSessions(
|
||||
cappedSessions: AiVaultSession[],
|
||||
scopeSessions: AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (scopeSessions.length === 0) {
|
||||
return cappedSessions
|
||||
}
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
for (const session of cappedSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
for (const session of scopeSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
}
|
||||
|
||||
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
|
||||
const cwd = session.cwd
|
||||
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
|
||||
}
|
||||
|
||||
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
|
||||
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function statRemoteFile(
|
||||
provider: IFilesystemProvider,
|
||||
path: string,
|
||||
agent: AiVaultAgent,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
try {
|
||||
const stat = await provider.stat(path)
|
||||
const mtimeMs = remoteStatMtimeMs(stat)
|
||||
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
|
||||
} catch (err) {
|
||||
issues.push({ executionHostId, agent, path, message: errorMessage(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStatMtimeMs(stat: FileStat): number {
|
||||
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
|
||||
return stat.mtimeMs
|
||||
}
|
||||
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
|
||||
}
|
||||
|
||||
function canStopParsingRemoteSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
nextCandidateMtimeMs: number | undefined
|
||||
): boolean {
|
||||
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
|
||||
return false
|
||||
}
|
||||
const visibleCutoff = sessions
|
||||
.map(sessionSortTime)
|
||||
.sort((left, right) => right - left)
|
||||
.at(limit - 1)
|
||||
|
||||
// Transcript mtimes bound the remaining candidate order; once the visible
|
||||
// cutoff is newer, older files cannot enter the unscoped top-N result.
|
||||
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
|
||||
}
|
||||
|
||||
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
|
||||
return Boolean(session)
|
||||
}
|
||||
|
||||
async function mapRemoteScanConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AiVaultSession,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
@@ -43,7 +44,11 @@ export function createAccumulator(args: {
|
||||
export function finalizeSession(
|
||||
accumulator: SessionAccumulator,
|
||||
platform: NodeJS.Platform,
|
||||
options: { codexHome?: string | null } = {}
|
||||
options: {
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
} = {}
|
||||
): AiVaultSession | null {
|
||||
const sessionId = accumulator.sessionId.trim()
|
||||
if (!sessionId) {
|
||||
@@ -54,8 +59,14 @@ export function finalizeSession(
|
||||
accumulator.fallbackTitle ||
|
||||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
|
||||
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
|
||||
return {
|
||||
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
executionHostId,
|
||||
...(options.executionHostPlatform
|
||||
? { executionHostPlatform: options.executionHostPlatform }
|
||||
: {}),
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
title,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import {
|
||||
parseDroidSessionFile,
|
||||
parseMessageGraphSessionFile,
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
@@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTit
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
codexHome: string | null = null,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
return parseCodexSessionLines({
|
||||
file,
|
||||
lines,
|
||||
platform,
|
||||
codexHome,
|
||||
executionHostId,
|
||||
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseCodexSessionContent(args: {
|
||||
file: FileWithMtime
|
||||
content: string
|
||||
platform?: NodeJS.Platform
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
readIndexedTitle?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return parseCodexSessionLines({
|
||||
file: args.file,
|
||||
lines: args.content.split(/\r?\n/),
|
||||
platform: args.platform ?? process.platform,
|
||||
codexHome: args.codexHome ?? null,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform,
|
||||
titleReader: args.readIndexedTitle
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCodexSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
codexHome: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
titleReader?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -70,7 +111,7 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
|
||||
(await args.titleReader?.(accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
@@ -158,7 +199,11 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
return finalizeSession(accumulator, args.platform, {
|
||||
codexHome: args.codexHome,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,11 +18,25 @@ import {
|
||||
numberValue
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDevinSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export function parseDevinSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -69,7 +84,7 @@ export async function parseDevinSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function extractDevinStepText(step: Record<string, unknown>): string | null {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
asRecord,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseDroidSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
export async function parseDroidSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseDroidSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseDroidSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
consumeDroidMessage(accumulator, record)
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
function consumeDroidMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown>
|
||||
): void {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
return
|
||||
}
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,15 +18,18 @@ import {
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
firstString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
readJsonObjectIfExists,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
@@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent,
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseMessageGraphSessionLines({ agent, file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseMessageGraphSessionContent(
|
||||
agent: 'openclaw' | 'pi',
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionLines({
|
||||
agent,
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseMessageGraphSessionLines(args: {
|
||||
agent: 'openclaw' | 'pi'
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: args.agent,
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ??
|
||||
extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -23,24 +24,51 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseClaudeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseClaudeSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseClaudeSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseClaudeSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseClaudeSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -92,7 +120,7 @@ export async function parseClaudeSessionFile(
|
||||
}
|
||||
|
||||
accumulator.fallbackTitle = generatedTitle ?? metaTitle
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
@@ -103,7 +131,33 @@ export async function parseGeminiSessionFile(
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
return parseGeminiJsonSessionContent(file, content, platform, options)
|
||||
}
|
||||
|
||||
function parseGeminiJsonSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -117,24 +171,33 @@ export async function parseGeminiSessionFile(
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
consumeGeminiMessage(accumulator, asRecord(message))
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile(
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export function consumeGeminiMessage(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -29,21 +30,49 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseCopilotSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCopilotSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCopilotSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCopilotSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCopilotSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -94,24 +123,47 @@ export async function parseCopilotSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseCursorSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCursorSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCursorSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCursorSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCursorSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -132,7 +184,7 @@ export async function parseCursorSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseOpenCodeSessionFile(
|
||||
@@ -207,7 +259,16 @@ export async function parseHermesSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseHermesSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -234,5 +295,5 @@ export async function parseHermesSessionFile(
|
||||
if (accumulator.messageCount === 0) {
|
||||
accumulator.messageCount = numberValue(record.message_count)
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AiVaultSession,
|
||||
AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type AiVaultScanOptions = {
|
||||
claudeProjectsDir?: string
|
||||
@@ -33,6 +34,7 @@ export type AiVaultScanOptions = {
|
||||
// the recency cap (see discoverInScopeClaudeFiles).
|
||||
scopePaths?: readonly string[]
|
||||
platform?: NodeJS.Platform
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
|
||||
@@ -41,6 +42,7 @@ export async function scanAiVaultSessions(
|
||||
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
|
||||
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
|
||||
const platform = options.platform ?? process.platform
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
|
||||
|
||||
@@ -63,6 +65,7 @@ export async function scanAiVaultSessions(
|
||||
candidates,
|
||||
limit,
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
@@ -75,12 +78,13 @@ export async function scanAiVaultSessions(
|
||||
scopePaths: options.scopePaths ?? [],
|
||||
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeSessions(cappedSessions, scopeSessions),
|
||||
issues,
|
||||
issues: issues.map((issue) => ({ executionHostId, ...issue })),
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -110,6 +114,7 @@ async function scanInScopeSessions(args: {
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
@@ -136,6 +141,7 @@ async function scanInScopeSessions(args: {
|
||||
candidates,
|
||||
limit: candidates.length,
|
||||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
issues: args.issues
|
||||
})
|
||||
}
|
||||
@@ -144,6 +150,7 @@ async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
limit: number
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
@@ -159,7 +166,9 @@ async function parseSessionCandidates(args: {
|
||||
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
|
||||
batch.map((candidate) =>
|
||||
parseSessionCandidate(candidate, args.platform, args.executionHostId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
@@ -179,15 +188,20 @@ async function parseSessionCandidates(args: {
|
||||
|
||||
async function parseSessionCandidate(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
platform: NodeJS.Platform,
|
||||
executionHostId: ExecutionHostId
|
||||
): Promise<SessionParseResult> {
|
||||
try {
|
||||
const session = await parseAgentSessionFile(candidate, platform)
|
||||
return { session, issue: null }
|
||||
return {
|
||||
session: session ? withSessionExecutionHost(session, executionHostId) : null,
|
||||
issue: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
session: null,
|
||||
issue: {
|
||||
executionHostId,
|
||||
agent: candidate.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
@@ -196,6 +210,20 @@ async function parseSessionCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionExecutionHost(
|
||||
session: AiVaultSession,
|
||||
executionHostId: ExecutionHostId
|
||||
): AiVaultSession {
|
||||
if (session.executionHostId === executionHostId) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
executionHostId,
|
||||
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function canStopParsingSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { on: vi.fn() },
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
|
||||
getSshFilesystemProvider: mocks.getSshFilesystemProvider
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
|
||||
}))
|
||||
|
||||
const { _internals } = await import('./ai-vault')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
})
|
||||
|
||||
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.objectContaining({
|
||||
scopePaths: ['/repo'],
|
||||
executionHostId: 'local'
|
||||
})
|
||||
)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes SSH scope to only that SSH target', async () => {
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('merges local plus connected SSH targets for all hosts', async () => {
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
})
|
||||
|
||||
it('returns a scan issue for a disconnected SSH target', async () => {
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
|
||||
|
||||
const result = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:disconnected'
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toMatchObject([
|
||||
{
|
||||
executionHostId: 'ssh:disconnected',
|
||||
agent: 'codex',
|
||||
path: 'disconnected'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps host scope in the cache key', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local' })
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
function hostInfo(targetId: string) {
|
||||
return {
|
||||
targetId,
|
||||
executionHostId: `ssh:${targetId}` as const,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
}
|
||||
}
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/tmp/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt:
|
||||
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
+160
-14
@@ -1,8 +1,26 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
|
||||
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
|
||||
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
|
||||
@@ -22,10 +40,14 @@ let inflightKey: string | null = null
|
||||
let handlerOptions: AiVaultHandlerOptions = {}
|
||||
|
||||
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? []
|
||||
scopePaths: args?.scopePaths ?? [],
|
||||
executionHostScope
|
||||
})
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
@@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
}
|
||||
|
||||
inflightKey = key
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
inflightList = (async () =>
|
||||
scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs()
|
||||
}))()
|
||||
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
|
||||
.then((result) => {
|
||||
cachedList = {
|
||||
key,
|
||||
@@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
// Only clear tracking if it still refers to this request: a concurrent
|
||||
// different-scope scan may have replaced it and must stay dedupable.
|
||||
if (inflightKey === key) {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
}
|
||||
})
|
||||
return inflightList
|
||||
}
|
||||
|
||||
async function scanAiVaultSessionsByHostScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
executionHostScope: ExecutionHostScope
|
||||
): Promise<AiVaultListResult> {
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args)
|
||||
}
|
||||
|
||||
if (executionHostScope === 'all') {
|
||||
return mergeAiVaultListResults(
|
||||
await Promise.all([
|
||||
scanLocalAiVaultSessions(args),
|
||||
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args)
|
||||
)
|
||||
]),
|
||||
args?.limit
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = parseExecutionHostId(executionHostScope)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return scanSshAiVaultSessions(parsed.targetId, args)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: executionHostScope,
|
||||
agent: 'codex',
|
||||
path: executionHostScope,
|
||||
message: 'Agent Session History is not available for this execution host.'
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
return scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs(),
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
}
|
||||
|
||||
async function scanSshAiVaultSessions(
|
||||
targetId: string,
|
||||
args?: AiVaultListArgs
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toSshExecutionHostId(targetId)
|
||||
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
|
||||
const provider = getSshFilesystemProvider(targetId)
|
||||
if (!hostInfo || !provider) {
|
||||
return sshScanIssueResult({
|
||||
executionHostId,
|
||||
targetId,
|
||||
message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
})
|
||||
}
|
||||
return scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths
|
||||
})
|
||||
}
|
||||
|
||||
function sshScanIssueResult(args: {
|
||||
executionHostId: `ssh:${string}`
|
||||
targetId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
handlerOptions = options
|
||||
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
|
||||
@@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
})
|
||||
}
|
||||
|
||||
function resetAiVaultCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
inflightKey = null
|
||||
handlerOptions = {}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
listAiVaultSessions,
|
||||
resetAiVaultCacheForTests
|
||||
}
|
||||
|
||||
async function getAiVaultWslHomeDirs(): Promise<string[]> {
|
||||
if (process.platform !== 'win32') {
|
||||
return []
|
||||
|
||||
+18
-1
@@ -5,7 +5,7 @@ import type { Store } from '../persistence'
|
||||
import { SshConnectionStore } from '../ssh/ssh-connection-store'
|
||||
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SshRelaySession } from '../ssh/ssh-relay-session'
|
||||
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
DetectedPort,
|
||||
@@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
|
||||
// scattered Maps/Sets that previously tracked this state independently.
|
||||
const activeSessions = new Map<string, SshRelaySession>()
|
||||
|
||||
export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
return null
|
||||
}
|
||||
return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
|
||||
}
|
||||
|
||||
export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
|
||||
return [...activeSessions.values()].flatMap((session) => {
|
||||
if (isRuntimeOwnedSshTargetId(session.targetId)) {
|
||||
return []
|
||||
}
|
||||
const info = session.getAiVaultHostInfo()
|
||||
return info ? [info] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function detachActiveSshSession(targetId: string): Promise<void> {
|
||||
await teardownActiveSshSession(targetId, (session) => session.detach())
|
||||
}
|
||||
|
||||
@@ -62,10 +62,12 @@ import {
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
|
||||
|
||||
type RemoteCliBridgeEnv = {
|
||||
remoteHome: string
|
||||
binDir: string
|
||||
relayDir: string
|
||||
nodePath: string
|
||||
@@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = {
|
||||
deliveredAt: number
|
||||
}
|
||||
|
||||
export type SshRelayAiVaultHostInfo = {
|
||||
targetId: string
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
|
||||
const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000
|
||||
const REPLAY_FINGERPRINT_EDGE_CHARS = 128
|
||||
|
||||
@@ -189,6 +198,19 @@ export class SshRelaySession {
|
||||
return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform
|
||||
}
|
||||
|
||||
getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null {
|
||||
const env = this.remoteCliBridgeEnv
|
||||
if (!env) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
targetId: this.targetId,
|
||||
executionHostId: toSshExecutionHostId(this.targetId),
|
||||
remoteHome: env.remoteHome,
|
||||
hostPlatform: env.hostPlatform
|
||||
}
|
||||
}
|
||||
|
||||
getPortScanner(): PortScanner | null {
|
||||
return this.portScanner
|
||||
}
|
||||
@@ -218,6 +240,7 @@ export class SshRelaySession {
|
||||
this.remoteCliBridgeEnv =
|
||||
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
|
||||
? {
|
||||
remoteHome,
|
||||
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
|
||||
relayDir: remoteRelayDir,
|
||||
nodePath,
|
||||
@@ -345,6 +368,7 @@ export class SshRelaySession {
|
||||
this.remoteCliBridgeEnv =
|
||||
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
|
||||
? {
|
||||
remoteHome,
|
||||
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
|
||||
relayDir: remoteRelayDir,
|
||||
nodePath,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
|
||||
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
|
||||
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
|
||||
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
|
||||
|
||||
export default function AiVaultPanel(): React.JSX.Element {
|
||||
const activeWorktreeId = useActiveWorktreeId()
|
||||
@@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
'runtime',
|
||||
[activeWorktreeId, resumeTargetState]
|
||||
)
|
||||
const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } =
|
||||
useAiVaultExecutionHostScope({
|
||||
activeWorktreeId: activeWorktreeId ?? null,
|
||||
resumeTargetState
|
||||
})
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
|
||||
const activeWorktreePaths = useMemo(
|
||||
@@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
}),
|
||||
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
|
||||
)
|
||||
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths)
|
||||
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(
|
||||
scopePaths,
|
||||
executionHostScope
|
||||
)
|
||||
const sessionProjectById = useMemo(
|
||||
() =>
|
||||
buildAiVaultProjectContext({
|
||||
@@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
)
|
||||
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
|
||||
sessions,
|
||||
repos,
|
||||
worktrees: allWorktrees,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null
|
||||
})
|
||||
@@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: session.filePath,
|
||||
sessionExecutionHostId: session.executionHostId,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
@@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: session.filePath,
|
||||
sessionExecutionHostId: session.executionHostId,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
@@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
activeWorktreePath={activeWorktreePath}
|
||||
activeProjectKey={activeProjectKey}
|
||||
scope={scope}
|
||||
executionHostScope={executionHostScope}
|
||||
activeSshExecutionHostScope={activeSshExecutionHostScope}
|
||||
agents={agents}
|
||||
sort={sort}
|
||||
group={group}
|
||||
@@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
adjustmentCount={viewAdjustmentCount}
|
||||
onQueryChange={setQuery}
|
||||
onScopeChange={handleScopeChange}
|
||||
onExecutionHostScopeChange={onExecutionHostScopeChange}
|
||||
onAgentEnabledChange={setAgentEnabled}
|
||||
onSortChange={setSort}
|
||||
onGroupChange={setGroup}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
FolderOpen,
|
||||
ListFilter,
|
||||
LoaderCircle,
|
||||
PanelsTopLeft
|
||||
PanelsTopLeft,
|
||||
Server
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -31,6 +32,12 @@ import {
|
||||
type AiVaultScope,
|
||||
type AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
getExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
type ExecutionHostScope
|
||||
} from '../../../../shared/execution-host'
|
||||
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -175,6 +182,59 @@ export function VaultScopeSwitch({
|
||||
)
|
||||
}
|
||||
|
||||
export function VaultHostScopeMenu({
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
onExecutionHostScopeChange
|
||||
}: {
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
}): React.JSX.Element {
|
||||
const hostOptions = [
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []),
|
||||
ALL_EXECUTION_HOSTS_SCOPE
|
||||
] as const
|
||||
const label = getExecutionHostLabel(executionHostScope)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 max-w-24 shrink-0 gap-1 px-1.5 text-[11px] font-medium text-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground @max-[340px]/ai-vault:w-6 @max-[340px]/ai-vault:px-0"
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.AiVaultPanelControls.hostScopeAriaLabel',
|
||||
'Session History host: {{value0}}',
|
||||
{ value0: label }
|
||||
)}
|
||||
>
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate @max-[340px]/ai-vault:hidden">{label}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6} className="w-44">
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={executionHostScope}
|
||||
onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)}
|
||||
>
|
||||
{hostOptions.map((scope) => (
|
||||
<DropdownMenuRadioItem key={scope} value={scope}>
|
||||
{getExecutionHostLabel(scope)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function VaultViewMenu({
|
||||
agents,
|
||||
sort,
|
||||
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
AiVaultScope,
|
||||
AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
|
||||
|
||||
type AiVaultPanelHeaderProps = {
|
||||
query: string
|
||||
@@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = {
|
||||
activeWorktreePath: string | null
|
||||
activeProjectKey: string | null
|
||||
scope: AiVaultScope
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
agents: readonly AiVaultAgent[]
|
||||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
@@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = {
|
||||
adjustmentCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onScopeChange: (scope: AiVaultScope) => void
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
|
||||
onSortChange: (sort: AiVaultSort) => void
|
||||
onGroupChange: (group: AiVaultGroup) => void
|
||||
@@ -42,6 +46,8 @@ export function AiVaultPanelHeader({
|
||||
activeWorktreePath,
|
||||
activeProjectKey,
|
||||
scope,
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
agents,
|
||||
sort,
|
||||
group,
|
||||
@@ -49,6 +55,7 @@ export function AiVaultPanelHeader({
|
||||
adjustmentCount,
|
||||
onQueryChange,
|
||||
onScopeChange,
|
||||
onExecutionHostScopeChange,
|
||||
onAgentEnabledChange,
|
||||
onSortChange,
|
||||
onGroupChange,
|
||||
@@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
|
||||
<VaultHostScopeMenu
|
||||
executionHostScope={executionHostScope}
|
||||
activeSshExecutionHostScope={activeSshExecutionHostScope}
|
||||
onExecutionHostScopeChange={onExecutionHostScopeChange}
|
||||
/>
|
||||
<VaultViewMenu
|
||||
agents={agents}
|
||||
sort={sort}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function SessionActionMenuItems({
|
||||
menuKind = 'dropdown',
|
||||
resumeDisabled,
|
||||
resumeLabel,
|
||||
onResume,
|
||||
onJumpToOriginalPane,
|
||||
showJumpToWorktree,
|
||||
onJumpToWorktree,
|
||||
onCopyResume,
|
||||
onCopyId,
|
||||
onCopyPath,
|
||||
onOpenLog,
|
||||
onRevealLog,
|
||||
onOpenCwd
|
||||
}: {
|
||||
menuKind?: 'dropdown' | 'context'
|
||||
resumeDisabled: boolean
|
||||
resumeLabel: string
|
||||
onResume: () => void
|
||||
onJumpToOriginalPane?: () => void
|
||||
showJumpToWorktree: boolean
|
||||
onJumpToWorktree?: () => void
|
||||
onCopyResume: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog?: () => void
|
||||
onRevealLog?: () => void
|
||||
onOpenCwd?: () => void
|
||||
}) {
|
||||
const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
|
||||
const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd)
|
||||
|
||||
return (
|
||||
<>
|
||||
{onJumpToOriginalPane ? (
|
||||
<Item onSelect={onJumpToOriginalPane}>
|
||||
<LocateFixed className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
|
||||
'Jump to Original Pane'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
{showJumpToWorktree ? (
|
||||
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
|
||||
<PanelTopOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
|
||||
'Jump to Worktree'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item disabled={resumeDisabled} onSelect={onResume}>
|
||||
<Play className="size-3.5" />
|
||||
{resumeLabel}
|
||||
</Item>
|
||||
<Item onSelect={onCopyResume}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
|
||||
'Copy Resume Command'
|
||||
)}
|
||||
</Item>
|
||||
{hasLocalPathActions ? (
|
||||
<>
|
||||
<Separator />
|
||||
{onOpenLog ? (
|
||||
<Item onSelect={onOpenLog}>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
|
||||
</Item>
|
||||
) : null}
|
||||
{onRevealLog ? (
|
||||
<Item onSelect={onRevealLog}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
|
||||
</Item>
|
||||
) : null}
|
||||
{onOpenCwd ? (
|
||||
<Item onSelect={onOpenCwd}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
|
||||
'Open Working Directory'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Separator />
|
||||
<Item onSelect={onCopyId}>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
|
||||
'Copy Session ID'
|
||||
)}
|
||||
</Item>
|
||||
<Item onSelect={onCopyPath}>
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function SessionInlineDetails({
|
||||
}
|
||||
onResumeInWorktree: () => void
|
||||
onResumeInNewTab: () => void
|
||||
onOpenLog: () => void
|
||||
onOpenLog?: () => void
|
||||
}): React.JSX.Element {
|
||||
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
|
||||
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
|
||||
@@ -132,20 +132,22 @@ export function SessionInlineDetails({
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenLog()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
|
||||
</Button>
|
||||
{onOpenLog ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenLog()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
@@ -27,6 +19,7 @@ import { agentLabel } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
|
||||
import { latestSessionConversationTurn } from './ai-vault-session-display'
|
||||
import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems'
|
||||
import { SessionRowTrailingActions } from './SessionRowTrailingActions'
|
||||
import type { AiVaultSessionResumeActions } from './ai-vault-session-resume'
|
||||
import {
|
||||
@@ -74,8 +67,8 @@ export function VaultSessionRow({
|
||||
onCopyResume: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog: () => void
|
||||
onRevealLog: () => void
|
||||
onOpenLog?: () => void
|
||||
onRevealLog?: () => void
|
||||
onOpenCwd?: () => void
|
||||
}) {
|
||||
const updatedAt = session.updatedAt ?? session.modifiedAt
|
||||
@@ -102,6 +95,7 @@ export function VaultSessionRow({
|
||||
title: session.title,
|
||||
command: resumeStartup.command,
|
||||
sessionFilePath: session.filePath,
|
||||
sessionExecutionHostId: session.executionHostId,
|
||||
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
|
||||
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
|
||||
})
|
||||
@@ -223,101 +217,6 @@ export function VaultSessionRow({
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionActionMenuItems({
|
||||
menuKind = 'dropdown',
|
||||
resumeDisabled,
|
||||
resumeLabel,
|
||||
onResume,
|
||||
onJumpToOriginalPane,
|
||||
showJumpToWorktree,
|
||||
onJumpToWorktree,
|
||||
onCopyResume,
|
||||
onCopyId,
|
||||
onCopyPath,
|
||||
onOpenLog,
|
||||
onRevealLog,
|
||||
onOpenCwd
|
||||
}: {
|
||||
menuKind?: 'dropdown' | 'context'
|
||||
resumeDisabled: boolean
|
||||
resumeLabel: string
|
||||
onResume: () => void
|
||||
onJumpToOriginalPane?: () => void
|
||||
showJumpToWorktree: boolean
|
||||
onJumpToWorktree?: () => void
|
||||
onCopyResume: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog: () => void
|
||||
onRevealLog: () => void
|
||||
onOpenCwd?: () => void
|
||||
}) {
|
||||
const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
|
||||
const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{onJumpToOriginalPane ? (
|
||||
<Item onSelect={onJumpToOriginalPane}>
|
||||
<LocateFixed className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
|
||||
'Jump to Original Pane'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
{showJumpToWorktree ? (
|
||||
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
|
||||
<PanelTopOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
|
||||
'Jump to Worktree'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item disabled={resumeDisabled} onSelect={onResume}>
|
||||
<Play className="size-3.5" />
|
||||
{resumeLabel}
|
||||
</Item>
|
||||
<Item onSelect={onCopyResume}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
|
||||
'Copy Resume Command'
|
||||
)}
|
||||
</Item>
|
||||
<Separator />
|
||||
<Item onSelect={onOpenLog}>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
|
||||
</Item>
|
||||
<Item onSelect={onRevealLog}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
|
||||
</Item>
|
||||
{onOpenCwd ? (
|
||||
<Item onSelect={onOpenCwd}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
|
||||
'Open Working Directory'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
<Separator />
|
||||
<Item onSelect={onCopyId}>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
|
||||
'Copy Session ID'
|
||||
)}
|
||||
</Item>
|
||||
<Item onSelect={onCopyPath}>
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function getSessionDetailsId(sessionId: string): string {
|
||||
return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
isAiVaultSessionInCurrentWorktree,
|
||||
type AiVaultSessionWorktreeInfo
|
||||
} from './ai-vault-session-worktree'
|
||||
import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions'
|
||||
import {
|
||||
extractVaultVirtualRowIndexes,
|
||||
getVaultStickyHeaderIndexes,
|
||||
@@ -275,6 +276,8 @@ function AiVaultVirtualRow({
|
||||
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
|
||||
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
|
||||
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
|
||||
const canOpenLocalSessionPaths =
|
||||
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -330,9 +333,11 @@ function AiVaultVirtualRow({
|
||||
onCopyResume={() => onCopyResume(row.session, resumeState?.worktreeId)}
|
||||
onCopyId={() => onCopyId(row.session)}
|
||||
onCopyPath={() => onCopyPath(row.session)}
|
||||
onOpenLog={() => onOpenLog(row.session)}
|
||||
onRevealLog={() => onRevealLog(row.session)}
|
||||
onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined}
|
||||
onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined}
|
||||
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
|
||||
onOpenCwd={
|
||||
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { agentLabel } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SessionActionMenuItems } from './AiVaultSessionRow'
|
||||
import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems'
|
||||
import {
|
||||
aiVaultWorktreeJumpTooltip,
|
||||
type AiVaultSessionWorktreeInfo
|
||||
@@ -67,8 +67,8 @@ export function SessionRowTrailingActions({
|
||||
onCopyResume: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog: () => void
|
||||
onRevealLog: () => void
|
||||
onOpenLog?: () => void
|
||||
onRevealLog?: () => void
|
||||
onOpenCwd?: () => void
|
||||
}) {
|
||||
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
|
||||
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
|
||||
|
||||
type HostScopeResult = ReturnType<typeof useAiVaultExecutionHostScope>
|
||||
|
||||
let root: Root | null = null
|
||||
let latest: HostScopeResult | null = null
|
||||
|
||||
function HookProbe(props: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
}): null {
|
||||
latest = useAiVaultExecutionHostScope(props)
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderHook(props: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
}): Promise<void> {
|
||||
if (!root) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
}
|
||||
await act(async () => {
|
||||
root?.render(createElement(HookProbe, props))
|
||||
})
|
||||
}
|
||||
|
||||
function stateForWorktree(args: {
|
||||
worktreeId: string
|
||||
repoId: string
|
||||
executionHostId?: string | null
|
||||
hostId?: string | null
|
||||
}): AiVaultSessionResumeTargetState {
|
||||
return {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
repos: [
|
||||
{
|
||||
id: args.repoId,
|
||||
connectionId: null,
|
||||
executionHostId: args.executionHostId ?? 'local'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
[args.repoId]: [
|
||||
{
|
||||
id: args.worktreeId,
|
||||
repoId: args.repoId,
|
||||
hostId: args.hostId ?? null
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
latest = null
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('useAiVaultExecutionHostScope', () => {
|
||||
it('defaults SSH worktrees to their SSH execution host', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/remote/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/remote/repo',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:dev-box'
|
||||
})
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('ssh:dev-box')
|
||||
expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box')
|
||||
})
|
||||
|
||||
it('defaults local worktrees to local history', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/local/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/local/repo',
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('local')
|
||||
expect(latest?.activeSshExecutionHostScope).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves manual host scope changes across unrelated rerenders', async () => {
|
||||
const props = {
|
||||
activeWorktreeId: 'repo-1::/remote/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/remote/repo',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:dev-box'
|
||||
})
|
||||
}
|
||||
await renderHook(props)
|
||||
|
||||
await act(async () => {
|
||||
latest?.onExecutionHostScopeChange('all')
|
||||
})
|
||||
await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } })
|
||||
|
||||
expect(latest?.executionHostScope).toBe('all')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
|
||||
|
||||
export function useAiVaultExecutionHostScope(args: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
}): {
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
} {
|
||||
const userChangedHostScopeRef = useRef(false)
|
||||
const activeExecutionHostId = useMemo(
|
||||
() => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId),
|
||||
[args.activeWorktreeId, args.resumeTargetState]
|
||||
)
|
||||
const activeExecutionHost = parseExecutionHostId(activeExecutionHostId)
|
||||
const defaultExecutionHostScope: ExecutionHostScope =
|
||||
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID
|
||||
const activeSshExecutionHostScope: ExecutionHostScope | null =
|
||||
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null
|
||||
const [executionHostScope, setExecutionHostScope] =
|
||||
useState<ExecutionHostScope>(defaultExecutionHostScope)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: preserve an explicit user choice (e.g. "All") across incidental
|
||||
// rerenders, but reset to the new default once that choice no longer
|
||||
// applies to the active worktree's host.
|
||||
const allowedScopes = new Set<ExecutionHostScope>([
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : [])
|
||||
])
|
||||
if (!allowedScopes.has(executionHostScope)) {
|
||||
setExecutionHostScope(defaultExecutionHostScope)
|
||||
userChangedHostScopeRef.current = false
|
||||
return
|
||||
}
|
||||
if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) {
|
||||
setExecutionHostScope(defaultExecutionHostScope)
|
||||
}
|
||||
}, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope])
|
||||
|
||||
const handleExecutionHostScopeChange = useCallback(
|
||||
(nextScope: ExecutionHostScope) => {
|
||||
userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope
|
||||
setExecutionHostScope(nextScope)
|
||||
},
|
||||
[defaultExecutionHostScope]
|
||||
)
|
||||
|
||||
return {
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
onExecutionHostScopeChange: handleExecutionHostScopeChange
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'codex:session-1',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
title: 'Fix the pane focus',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'codex:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
title: 'Fix the flaky golden tests',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'claude:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
title: 'Implement vault filters',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
@@ -79,6 +80,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
(session: AiVaultSession, targetWorktreeId?: string): void => {
|
||||
const targetId = resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: session.filePath,
|
||||
sessionExecutionHostId: session.executionHostId,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
targetWorktreeId,
|
||||
targetState
|
||||
@@ -130,6 +132,7 @@ export type AiVaultSessionLaunchTarget =
|
||||
|
||||
export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
sessionFilePath: string | null
|
||||
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
|
||||
activeWorktreeId: string | null
|
||||
targetWorktreeId?: string
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
@@ -143,7 +146,18 @@ export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
|
||||
args.targetState,
|
||||
targetWorktreeId
|
||||
)
|
||||
if (
|
||||
!canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
})
|
||||
) {
|
||||
return { status: 'unsupported', targetStatus }
|
||||
}
|
||||
|
||||
@@ -159,12 +173,12 @@ function aiVaultResumeUnsupportedMessage(
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
}
|
||||
// Why: 'ssh' only reaches the unsupported branch when the session file lives
|
||||
// on this machine, so the message explains the host mismatch, not SSH itself.
|
||||
if (targetStatus === 'ssh') {
|
||||
// Why: local and SSH targets can both be valid generally; this branch means
|
||||
// the session's recorded host does not match the selected workspace.
|
||||
if (targetStatus === 'ssh' || targetStatus === 'local') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported',
|
||||
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
|
||||
'This session belongs to a different host. Open a workspace on the same host to resume it.'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions'
|
||||
|
||||
describe('canUseLocalAiVaultSessionPathActions', () => {
|
||||
it('allows OS path actions for local session history', () => {
|
||||
expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks OS path actions for non-local or unknown session history', () => {
|
||||
expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false)
|
||||
expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false)
|
||||
expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
|
||||
export function canUseLocalAiVaultSessionPathActions(
|
||||
executionHostId: ExecutionHostId | null | undefined
|
||||
): boolean {
|
||||
// Why: Electron shell open/reveal APIs only validate paths on this computer;
|
||||
// SSH session history exposes paths that exist on the remote host instead.
|
||||
return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'claude:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
title: 'Implement project history',
|
||||
@@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => {
|
||||
repoId: repo.id,
|
||||
path: '/runtime/orca'
|
||||
})
|
||||
const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' })
|
||||
const session = makeSession({
|
||||
id: 'claude:runtime-worktree',
|
||||
cwd: '/runtime/orca/src',
|
||||
executionHostId: 'runtime:preview'
|
||||
})
|
||||
|
||||
const context = buildAiVaultProjectContext({
|
||||
repos: [repo],
|
||||
@@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => {
|
||||
expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win')
|
||||
})
|
||||
|
||||
it('falls back to folder when a hostless session matches multiple host buckets', () => {
|
||||
it('uses the session host when matching overlapping local and SSH project paths', () => {
|
||||
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
|
||||
const sshRepo = makeRepo({
|
||||
id: 'ssh',
|
||||
@@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => {
|
||||
path: '/srv/orca',
|
||||
connectionId: 'target-1'
|
||||
})
|
||||
const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' })
|
||||
const session = makeSession({
|
||||
id: 'claude:ssh-session',
|
||||
cwd: '/srv/orca/src',
|
||||
executionHostId: 'ssh:target-1'
|
||||
})
|
||||
|
||||
const context = buildAiVaultProjectContext({
|
||||
repos: [localRepo, sshRepo],
|
||||
@@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => {
|
||||
})
|
||||
|
||||
expect(context.sessionProjectById.get(session.id)).toMatchObject({
|
||||
kind: 'folder',
|
||||
key: 'folder:/srv/orca/src',
|
||||
label: 'orca/src'
|
||||
kind: 'repo',
|
||||
key: 'repo:ssh',
|
||||
label: 'SSH',
|
||||
hostKey: 'ssh:target-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => {
|
||||
it('falls back to folder when a legacy hostless session matches multiple host buckets', () => {
|
||||
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
|
||||
const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' })
|
||||
const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' })
|
||||
const session = makeSession({
|
||||
id: 'claude:runtime-ambiguous',
|
||||
cwd: '/srv/orca/src',
|
||||
executionHostId: undefined as unknown as AiVaultSession['executionHostId']
|
||||
})
|
||||
|
||||
const context = buildAiVaultProjectContext({
|
||||
repos: [localRepo, runtimeRepo],
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
|
||||
@@ -14,7 +19,7 @@ export type AiVaultSessionProject = {
|
||||
label: string
|
||||
projectId?: string
|
||||
repoId?: string
|
||||
hostKey?: string
|
||||
hostKey?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type AiVaultProjectContext = {
|
||||
@@ -27,7 +32,7 @@ export type AiVaultProjectContext = {
|
||||
type SessionProjectCandidate = {
|
||||
source: 'worktree' | 'setup'
|
||||
normalizedPath: string
|
||||
hostKey: string
|
||||
hostKey: ExecutionHostId
|
||||
projectId: string | null
|
||||
repoId: string | null
|
||||
}
|
||||
@@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({
|
||||
for (const session of sessions) {
|
||||
sessionProjectById.set(
|
||||
session.id,
|
||||
resolveSessionProject(session.cwd, candidates, projectLabelByKey)
|
||||
resolveSessionProject(session, candidates, projectLabelByKey)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,10 +152,11 @@ function buildProjectCandidates(
|
||||
candidates.push({
|
||||
source: 'worktree',
|
||||
normalizedPath: normalizeRuntimePathForComparison(worktree.path),
|
||||
hostKey:
|
||||
worktree.hostId ??
|
||||
setup?.hostId ??
|
||||
(repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID),
|
||||
hostKey: resolveCandidateHostId(
|
||||
worktree.hostId,
|
||||
setup?.hostId,
|
||||
repo ? getRepoExecutionHostId(repo) : null
|
||||
),
|
||||
projectId: worktree.projectId ?? setup?.projectId ?? null,
|
||||
repoId: worktree.repoId
|
||||
})
|
||||
@@ -168,7 +174,7 @@ function buildProjectCandidates(
|
||||
candidates.push({
|
||||
source: 'setup',
|
||||
normalizedPath: normalizeRuntimePathForComparison(setup.path),
|
||||
hostKey: setup.hostId || getRepoExecutionHostId(setup),
|
||||
hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)),
|
||||
projectId: setup.projectId,
|
||||
repoId: setup.repoId || null
|
||||
})
|
||||
@@ -193,15 +199,28 @@ function buildProjectCandidates(
|
||||
return candidates
|
||||
}
|
||||
|
||||
function resolveCandidateHostId(
|
||||
...values: readonly (string | null | undefined)[]
|
||||
): ExecutionHostId {
|
||||
for (const value of values) {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (hostId) {
|
||||
return hostId
|
||||
}
|
||||
}
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
function hasCandidatePath(pathValue: string): boolean {
|
||||
return pathValue.trim().length > 0
|
||||
}
|
||||
|
||||
function resolveSessionProject(
|
||||
cwd: string | null,
|
||||
session: AiVaultSession,
|
||||
candidates: readonly SessionProjectCandidate[],
|
||||
projectLabelByKey: ReadonlyMap<string, string>
|
||||
): AiVaultSessionProject {
|
||||
const cwd = session.cwd
|
||||
if (!cwd) {
|
||||
return { kind: 'unknown', key: 'unknown', label: '' }
|
||||
}
|
||||
@@ -209,14 +228,22 @@ function resolveSessionProject(
|
||||
const matches = candidates.filter((candidate) =>
|
||||
isPathInsideOrEqual(candidate.normalizedPath, cwd)
|
||||
)
|
||||
const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey))
|
||||
if (hostBuckets.size > 1) {
|
||||
// Why: session rows do not carry host ids yet, so overlapping local/SSH
|
||||
// paths must stay visible without being attributed to the wrong project.
|
||||
const sessionHostId = normalizeExecutionHostId(session.executionHostId)
|
||||
const hostMatches = sessionHostId
|
||||
? matches.filter((candidate) => candidate.hostKey === sessionHostId)
|
||||
: matches
|
||||
if (sessionHostId && matches.length > 0 && hostMatches.length === 0) {
|
||||
// Why: same-path projects can exist on several hosts; a tagged transcript
|
||||
// should never be attributed to a project on a different machine.
|
||||
return folderProject(cwd)
|
||||
}
|
||||
|
||||
const bestCandidate = matches.sort(compareCandidates)[0]
|
||||
const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey))
|
||||
if (!sessionHostId && hostBuckets.size > 1) {
|
||||
return folderProject(cwd)
|
||||
}
|
||||
|
||||
const bestCandidate = hostMatches.sort(compareCandidates)[0]
|
||||
if (!bestCandidate) {
|
||||
return folderProject(cwd)
|
||||
}
|
||||
|
||||
@@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState()
|
||||
const roots: Root[] = []
|
||||
let latest: ReturnType<typeof useAiVaultSessionRefresh> | null = null
|
||||
|
||||
function HookProbe(props: { scopePaths: readonly string[] }): null {
|
||||
latest = useAiVaultSessionRefresh(props.scopePaths)
|
||||
function HookProbe(props: {
|
||||
scopePaths: readonly string[]
|
||||
executionHostScope?: 'local' | 'all' | `ssh:${string}`
|
||||
}): null {
|
||||
latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local')
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderHook(scopePaths: readonly string[] = []): Promise<void> {
|
||||
async function renderHook(
|
||||
scopePaths: readonly string[] = [],
|
||||
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
|
||||
): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { scopePaths }))
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
|
||||
})
|
||||
}
|
||||
|
||||
async function rerenderHook(
|
||||
scopePaths: readonly string[] = [],
|
||||
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
|
||||
): Promise<void> {
|
||||
const root = roots.at(-1)
|
||||
if (!root) {
|
||||
throw new Error('renderHook must be called before rerenderHook')
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,7 +145,58 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true })
|
||||
expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
executionHostScope: 'local',
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the requested execution host scope to the scanner', async () => {
|
||||
await renderHook(['/repo'], 'ssh:dev-box')
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(lastCallArgs()).toMatchObject({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/repo']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply stale results after the host scope changes mid-scan', async () => {
|
||||
let resolveLocal: ((result: AiVaultListResult) => void) | null = null
|
||||
let resolveSsh: ((result: AiVaultListResult) => void) | null = null
|
||||
listSessionsMock
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<AiVaultListResult>((resolve) => (resolveLocal = resolve))
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<AiVaultListResult>((resolve) => (resolveSsh = resolve))
|
||||
)
|
||||
|
||||
await renderHook([], 'local')
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await rerenderHook(['/remote/repo'], 'ssh:dev-box')
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' })
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(latest?.scanResult).toBeNull()
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(lastCallArgs()).toMatchObject({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/remote/repo']
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' })
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z')
|
||||
})
|
||||
|
||||
it('force re-scans on refocus once the throttle allows it', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const SESSION_LIMIT = 500
|
||||
@@ -26,7 +27,10 @@ export function resetAiVaultForcedRescanThrottleForTest(): void {
|
||||
|
||||
type AiVaultRefreshArgs = { force?: boolean; background?: boolean }
|
||||
|
||||
export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
|
||||
export function useAiVaultSessionRefresh(
|
||||
scopePaths: readonly string[],
|
||||
executionHostScope: ExecutionHostScope
|
||||
): {
|
||||
error: string | null
|
||||
loading: boolean
|
||||
refresh: (args?: AiVaultRefreshArgs) => Promise<void>
|
||||
@@ -45,77 +49,99 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
|
||||
const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
|
||||
const scanScopeKey = `${executionHostScope}\n${scopePathsKey}`
|
||||
const scopePathsRef = useRef<readonly string[]>(scopePaths)
|
||||
scopePathsRef.current = scopePaths
|
||||
const executionHostScopeRef = useRef<ExecutionHostScope>(executionHostScope)
|
||||
executionHostScopeRef.current = executionHostScope
|
||||
const currentScanScopeKey = useCallback(
|
||||
() => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`,
|
||||
[]
|
||||
)
|
||||
|
||||
const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise<void> => {
|
||||
// A scope change during an in-flight scan must not be dropped; queue one more
|
||||
// scan so the current scoped view is refreshed after the older scan settles.
|
||||
if (refreshInFlightRef.current) {
|
||||
pendingRefreshRef.current = true
|
||||
pendingForceRef.current ||= args.force === true
|
||||
pendingBackgroundRef.current &&= args.background === true
|
||||
return
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true
|
||||
const refreshId = refreshIdRef.current + 1
|
||||
refreshIdRef.current = refreshId
|
||||
// A manual force scan counts against the throttle so an auto rescan right
|
||||
// after the button press doesn't trigger a second full scan.
|
||||
if (args.force === true) {
|
||||
lastForcedRescanAt = Date.now()
|
||||
}
|
||||
// Background (refocus) refreshes usually resolve from the main-process
|
||||
// cache; suppressing the loading flag avoids a spinner flash on every
|
||||
// return to the app.
|
||||
if (args.background !== true) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
const scopeKey = scopePathsRef.current.join('\n')
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
limit: SESSION_LIMIT,
|
||||
scopePaths: scopePathsRef.current,
|
||||
force: args.force
|
||||
})
|
||||
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
const refresh = useCallback(
|
||||
async (args: AiVaultRefreshArgs = {}): Promise<void> => {
|
||||
// A scope change during an in-flight scan must not be dropped; queue one more
|
||||
// scan so the current scoped view is refreshed after the older scan settles.
|
||||
if (refreshInFlightRef.current) {
|
||||
pendingRefreshRef.current = true
|
||||
pendingForceRef.current ||= args.force === true
|
||||
pendingBackgroundRef.current &&= args.background === true
|
||||
return
|
||||
}
|
||||
// A cache hit returns the snapshot already on screen; skip the state
|
||||
// updates so refocus flips don't force pointless re-renders.
|
||||
if (
|
||||
lastAppliedScanRef.current?.scopeKey === scopeKey &&
|
||||
lastAppliedScanRef.current.scannedAt === result.scannedAt
|
||||
) {
|
||||
return
|
||||
|
||||
refreshInFlightRef.current = true
|
||||
const refreshId = refreshIdRef.current + 1
|
||||
refreshIdRef.current = refreshId
|
||||
// A manual force scan counts against the throttle so an auto rescan right
|
||||
// after the button press doesn't trigger a second full scan.
|
||||
if (args.force === true) {
|
||||
lastForcedRescanAt = Date.now()
|
||||
}
|
||||
lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt }
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
} catch (err) {
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
// Background (refocus) refreshes usually resolve from the main-process
|
||||
// cache; suppressing the loading flag avoids a spinner flash on every
|
||||
// return to the app.
|
||||
if (args.background !== true) {
|
||||
setLoading(true)
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
const scopeKey = scopePathsRef.current.join('\n')
|
||||
const hostScope = executionHostScopeRef.current
|
||||
const scanKey = `${hostScope}\n${scopeKey}`
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
limit: SESSION_LIMIT,
|
||||
scopePaths: scopePathsRef.current,
|
||||
executionHostScope: hostScope,
|
||||
force: args.force
|
||||
})
|
||||
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
return
|
||||
}
|
||||
// Why: host/scope changes queue a follow-up scan, but the older result
|
||||
// may resolve first and must not briefly paint the wrong history list.
|
||||
if (scanKey !== currentScanScopeKey()) {
|
||||
return
|
||||
}
|
||||
// A cache hit returns the snapshot already on screen; skip the state
|
||||
// updates so refocus flips don't force pointless re-renders.
|
||||
if (
|
||||
lastAppliedScanRef.current?.scopeKey === scanKey &&
|
||||
lastAppliedScanRef.current.scannedAt === result.scannedAt
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt }
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
} catch (err) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
refreshIdRef.current === refreshId &&
|
||||
scanKey === currentScanScopeKey()
|
||||
) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setLoading(false)
|
||||
}
|
||||
if (pendingRefreshRef.current && mountedRef.current) {
|
||||
pendingRefreshRef.current = false
|
||||
const force = pendingForceRef.current
|
||||
// The queued refresh is background-only if every queued caller was.
|
||||
const background = pendingBackgroundRef.current
|
||||
pendingForceRef.current = false
|
||||
pendingBackgroundRef.current = true
|
||||
void refresh({ force, background })
|
||||
}
|
||||
}
|
||||
if (pendingRefreshRef.current && mountedRef.current) {
|
||||
pendingRefreshRef.current = false
|
||||
const force = pendingForceRef.current
|
||||
// The queued refresh is background-only if every queued caller was.
|
||||
const background = pendingBackgroundRef.current
|
||||
pendingForceRef.current = false
|
||||
pendingBackgroundRef.current = true
|
||||
void refresh({ force, background })
|
||||
}
|
||||
}
|
||||
// Deps are intentionally empty: refresh reads changing values through refs
|
||||
// and recurses on itself, so its identity must stay stable.
|
||||
}, [])
|
||||
// Deps intentionally avoid changing scope values: refresh reads them
|
||||
// through refs and recurses on itself, so its identity must stay stable.
|
||||
},
|
||||
[currentScanScopeKey]
|
||||
)
|
||||
|
||||
// Forced rescans triggered by events (refocus, agent-session starts) run
|
||||
// immediately when the throttle allows, otherwise once as soon as it frees
|
||||
@@ -162,7 +188,7 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
|
||||
if (!force) {
|
||||
requestForcedRescan()
|
||||
}
|
||||
}, [refresh, requestForcedRescan, scopePathsKey])
|
||||
}, [refresh, requestForcedRescan, scanScopeKey])
|
||||
|
||||
// Sessions started while the app was backgrounded should appear when the
|
||||
// user returns, so refocus also bypasses the scan cache (throttled). OS
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
@@ -34,6 +36,7 @@ export type AiVaultSessionResumeActions = {
|
||||
|
||||
export function resolveAiVaultSessionResumeState(args: {
|
||||
sessionFilePath: string | null
|
||||
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -56,6 +59,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
for (const worktreeId of candidateWorktreeIds) {
|
||||
const targetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
worktreeId,
|
||||
targetState
|
||||
})
|
||||
@@ -78,6 +82,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
|
||||
export function resolveAiVaultSessionResumeActions(args: {
|
||||
sessionFilePath: string | null
|
||||
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -92,11 +97,13 @@ export function resolveAiVaultSessionResumeActions(args: {
|
||||
|
||||
const sessionTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
worktreeId: sessionWorktreeId,
|
||||
targetState
|
||||
})
|
||||
const activeTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
worktreeId:
|
||||
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
|
||||
? args.activeWorktreeId
|
||||
@@ -142,6 +149,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
|
||||
|
||||
function resolveSupportedResumeWorktreeId(args: {
|
||||
sessionFilePath: string | null
|
||||
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
|
||||
worktreeId: string | null
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
}): string | null {
|
||||
@@ -154,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId)
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
|
||||
args.targetState,
|
||||
args.worktreeId
|
||||
)
|
||||
if (
|
||||
!canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
aiVaultWorktreeCompactPath,
|
||||
aiVaultWorktreeJumpTooltip,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'codex:session-1',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
title: 'Find the pane',
|
||||
@@ -55,6 +56,19 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return { ...worktree, ...overrides }
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo/orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
connectionId: null,
|
||||
executionHostId: 'local',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveAiVaultSessionWorktreeInfo', () => {
|
||||
it('marks the selected owning worktree as current', () => {
|
||||
const worktree = makeWorktree()
|
||||
@@ -135,6 +149,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => {
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the session host when multiple worktrees share the same path', () => {
|
||||
const localWorktree = makeWorktree({
|
||||
id: 'repo-local::/srv/orca',
|
||||
repoId: 'repo-local',
|
||||
displayName: 'local',
|
||||
path: '/srv/orca',
|
||||
hostId: 'local'
|
||||
})
|
||||
const sshWorktree = makeWorktree({
|
||||
id: 'repo-ssh::/srv/orca',
|
||||
repoId: 'repo-ssh',
|
||||
displayName: 'ssh',
|
||||
path: '/srv/orca',
|
||||
hostId: 'ssh:target-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' },
|
||||
worktrees: [localWorktree, sshWorktree],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({
|
||||
label: 'ssh',
|
||||
worktreeId: sshWorktree.id
|
||||
})
|
||||
})
|
||||
|
||||
it('uses repo host ownership when a legacy worktree lacks host metadata', () => {
|
||||
const worktree = makeWorktree({
|
||||
id: 'repo-ssh::/srv/orca',
|
||||
repoId: 'repo-ssh',
|
||||
displayName: 'ssh',
|
||||
path: '/srv/orca'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' },
|
||||
repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })],
|
||||
worktrees: [worktree],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({
|
||||
label: 'ssh',
|
||||
worktreeId: worktree.id
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('canJumpToAiVaultSessionWorktree', () => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import { parseWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
isRuntimePathAbsolute,
|
||||
@@ -8,7 +14,7 @@ import {
|
||||
normalizeRuntimePathSeparators
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable'
|
||||
@@ -23,16 +29,19 @@ export type AiVaultSessionWorktreeInfo = {
|
||||
type WorktreeCandidate = {
|
||||
worktree: Worktree
|
||||
path: string
|
||||
hostId: ExecutionHostId
|
||||
status: Exclude<AiVaultSessionWorktreeStatus, 'current'>
|
||||
source: 'current-path' | 'prior-path'
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionWorktreeInfo({
|
||||
session,
|
||||
repos = [],
|
||||
worktrees,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
session: AiVaultSession
|
||||
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
worktrees: readonly Worktree[]
|
||||
activeWorktreeId: string | null
|
||||
}): AiVaultSessionWorktreeInfo | null {
|
||||
@@ -40,8 +49,10 @@ export function resolveAiVaultSessionWorktreeInfo({
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = buildWorktreeCandidates(worktrees)
|
||||
const sessionHostId = normalizeExecutionHostId(session.executionHostId)
|
||||
const candidates = buildWorktreeCandidates(worktrees, repos)
|
||||
.filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
|
||||
.filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId)
|
||||
.sort(compareWorktreeCandidates)
|
||||
|
||||
const best = candidates[0]
|
||||
@@ -85,6 +96,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul
|
||||
|
||||
export function resolveAiVaultSessionWorktreeDisplay(args: {
|
||||
session: AiVaultSession
|
||||
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
worktrees: readonly Worktree[]
|
||||
activeWorktreeId: string | null
|
||||
}): AiVaultSessionWorktreeInfo | null {
|
||||
@@ -117,10 +129,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: {
|
||||
|
||||
export function useAiVaultSessionWorktreeMap({
|
||||
sessions,
|
||||
repos = [],
|
||||
worktrees,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
sessions: readonly AiVaultSession[]
|
||||
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
worktrees: readonly Worktree[]
|
||||
activeWorktreeId: string | null
|
||||
}): ReadonlyMap<string, AiVaultSessionWorktreeInfo> {
|
||||
@@ -130,13 +144,14 @@ export function useAiVaultSessionWorktreeMap({
|
||||
sessions.flatMap((session) => {
|
||||
const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({
|
||||
session,
|
||||
repos,
|
||||
worktrees,
|
||||
activeWorktreeId
|
||||
})
|
||||
return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
|
||||
})
|
||||
),
|
||||
[activeWorktreeId, sessions, worktrees]
|
||||
[activeWorktreeId, repos, sessions, worktrees]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,13 +207,22 @@ export function aiVaultWorktreeJumpTooltip(
|
||||
)
|
||||
}
|
||||
|
||||
function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] {
|
||||
function buildWorktreeCandidates(
|
||||
worktrees: readonly Worktree[],
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
): WorktreeCandidate[] {
|
||||
const candidates: WorktreeCandidate[] = []
|
||||
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
for (const worktree of worktrees) {
|
||||
const repo = repoById.get(worktree.repoId)
|
||||
const hostId =
|
||||
normalizeExecutionHostId(worktree.hostId) ??
|
||||
(repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID)
|
||||
if (hasUsablePath(worktree.path)) {
|
||||
candidates.push({
|
||||
worktree,
|
||||
path: worktree.path,
|
||||
hostId,
|
||||
status: worktree.isArchived ? 'archived' : 'active',
|
||||
source: 'current-path'
|
||||
})
|
||||
@@ -211,6 +235,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid
|
||||
candidates.push({
|
||||
worktree,
|
||||
path: parsed.worktreePath,
|
||||
hostId,
|
||||
status: worktree.isArchived ? 'archived' : 'active',
|
||||
source: 'prior-path'
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 're
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
@@ -169,7 +170,9 @@ export default function AiVaultSessionDropLayer({
|
||||
return true
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId)
|
||||
const state = useAppStore.getState()
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId)
|
||||
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId)
|
||||
if (targetStatus === 'runtime') {
|
||||
toast.error(
|
||||
translate(
|
||||
@@ -191,13 +194,15 @@ export default function AiVaultSessionDropLayer({
|
||||
if (
|
||||
!canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: payload.sessionFilePath ?? null,
|
||||
targetStatus
|
||||
sessionExecutionHostId: payload.sessionExecutionHostId ?? null,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
})
|
||||
) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported',
|
||||
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported',
|
||||
'This session belongs to a different host. Drop it onto a workspace on the same host.'
|
||||
)
|
||||
)
|
||||
return true
|
||||
|
||||
@@ -2589,6 +2589,7 @@
|
||||
"sessionQueued": "Session queued",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
@@ -9590,6 +9591,7 @@
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
@@ -9619,7 +9621,9 @@
|
||||
"globalScope": "Global",
|
||||
"projectScope": "Project",
|
||||
"currentProjectLower": "current project",
|
||||
"project": "Project"
|
||||
"project": "Project",
|
||||
"hostScopeAriaLabel": "Session History host: {{value0}}",
|
||||
"host": "Host"
|
||||
},
|
||||
"AiVaultSessionDetails": {
|
||||
"originalAsk": "Original ask",
|
||||
|
||||
@@ -2589,7 +2589,8 @@
|
||||
"sessionQueued": "Sesión en cola",
|
||||
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.",
|
||||
"openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial."
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
|
||||
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "Nueva división"
|
||||
@@ -9590,7 +9591,8 @@
|
||||
"runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.",
|
||||
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.",
|
||||
"openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial."
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
|
||||
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Buscando sesiones",
|
||||
@@ -9619,7 +9621,9 @@
|
||||
"globalScope": "Global",
|
||||
"projectScope": "Proyecto",
|
||||
"currentProjectLower": "proyecto actual",
|
||||
"project": "Proyecto"
|
||||
"project": "Proyecto",
|
||||
"hostScopeAriaLabel": "Session History host: {{value0}}",
|
||||
"host": "Host"
|
||||
},
|
||||
"AiVaultSessionDetails": {
|
||||
"updated": "Actualizado",
|
||||
|
||||
@@ -2589,7 +2589,8 @@
|
||||
"sessionQueued": "セッションをキューに追加しました",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。"
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。",
|
||||
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新しい分割"
|
||||
@@ -9590,7 +9591,8 @@
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。"
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。",
|
||||
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。"
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Scanning sessions",
|
||||
@@ -9619,7 +9621,9 @@
|
||||
"globalScope": "グローバル",
|
||||
"projectScope": "プロジェクト",
|
||||
"currentProjectLower": "現在のプロジェクト",
|
||||
"project": "プロジェクト"
|
||||
"project": "プロジェクト",
|
||||
"hostScopeAriaLabel": "Session History host: {{value0}}",
|
||||
"host": "Host"
|
||||
},
|
||||
"AiVaultSessionDetails": {
|
||||
"updated": "更新日時",
|
||||
|
||||
@@ -2589,7 +2589,8 @@
|
||||
"sessionQueued": "세션이 대기열에 추가됨",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요."
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.",
|
||||
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "새 분할"
|
||||
@@ -9590,7 +9591,8 @@
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요."
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.",
|
||||
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "세션 스캔 중",
|
||||
@@ -9619,7 +9621,9 @@
|
||||
"globalScope": "전역",
|
||||
"projectScope": "프로젝트",
|
||||
"currentProjectLower": "현재 프로젝트",
|
||||
"project": "프로젝트"
|
||||
"project": "프로젝트",
|
||||
"hostScopeAriaLabel": "Session History host: {{value0}}",
|
||||
"host": "Host"
|
||||
},
|
||||
"AiVaultSessionDetails": {
|
||||
"updated": "업데이트됨",
|
||||
|
||||
@@ -2589,7 +2589,8 @@
|
||||
"sessionQueued": "会话已排队",
|
||||
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。"
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。",
|
||||
"sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新建拆分"
|
||||
@@ -9590,7 +9591,8 @@
|
||||
"runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。",
|
||||
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。"
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。",
|
||||
"sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。"
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "正在扫描会话",
|
||||
@@ -9619,7 +9621,9 @@
|
||||
"globalScope": "全局",
|
||||
"projectScope": "项目",
|
||||
"currentProjectLower": "当前项目",
|
||||
"project": "项目"
|
||||
"project": "项目",
|
||||
"hostScopeAriaLabel": "Session History host: {{value0}}",
|
||||
"host": "Host"
|
||||
},
|
||||
"AiVaultSessionDetails": {
|
||||
"updated": "更新时间",
|
||||
|
||||
@@ -294,4 +294,111 @@ describe('ai vault resume command runtime', () => {
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('returns the remote resume command verbatim for non-local host sessions', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeStartupForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
session: {
|
||||
agent: 'codex',
|
||||
sessionId: 'session one',
|
||||
cwd: '/home/alice/repo',
|
||||
codexHome: null,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" })
|
||||
})
|
||||
|
||||
it('bypasses the resume pipeline even when the command override is blank', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommandForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
commandOverride: ' ',
|
||||
session: {
|
||||
agent: 'codex',
|
||||
sessionId: 'session one',
|
||||
cwd: '/home/alice/repo',
|
||||
codexHome: null,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'")
|
||||
})
|
||||
|
||||
it('rebuilds the command when a non-blank override is supplied for a remote session', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommandForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
commandOverride: 'my-codex',
|
||||
session: {
|
||||
agent: 'codex',
|
||||
sessionId: 'session one',
|
||||
cwd: '/home/alice/repo',
|
||||
codexHome: null,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('rebuilds overridden remote commands with the recorded remote host platform', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommandForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
commandOverride: 'my-codex',
|
||||
session: {
|
||||
agent: 'codex',
|
||||
sessionId: 'session one',
|
||||
cwd: 'C:/Users/alice/repo',
|
||||
codexHome: 'C:/Users/alice/.codex',
|
||||
executionHostId: 'ssh:win-box',
|
||||
executionHostPlatform: 'win32',
|
||||
resumeCommand:
|
||||
'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""'
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
"Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'"
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a stored resume command for local-host sessions', () => {
|
||||
const state = makeState({ worktreePath: '/home/alice/repo' })
|
||||
state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommandForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
session: {
|
||||
agent: 'codex',
|
||||
sessionId: 'session one',
|
||||
cwd: '/home/alice/repo',
|
||||
codexHome: null,
|
||||
executionHostId: 'local',
|
||||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,14 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
type AiVaultResumeCommandSession = Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'>
|
||||
type AiVaultResumeCommandSession = Pick<
|
||||
AiVaultSession,
|
||||
'agent' | 'sessionId' | 'cwd' | 'codexHome'
|
||||
> &
|
||||
Partial<Pick<AiVaultSession, 'executionHostId' | 'executionHostPlatform' | 'resumeCommand'>>
|
||||
|
||||
export type AiVaultResumeStartup = {
|
||||
command: string
|
||||
@@ -65,7 +69,20 @@ export function buildAiVaultResumeStartupForWorktree(args: {
|
||||
session: AiVaultResumeCommandSession
|
||||
commandOverride?: string | null
|
||||
}): AiVaultResumeStartup {
|
||||
const platform = getAiVaultResumePlatform(args.state, args.worktreeId)
|
||||
if (
|
||||
args.session.executionHostId &&
|
||||
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
|
||||
args.session.resumeCommand &&
|
||||
!args.commandOverride?.trim()
|
||||
) {
|
||||
return { command: args.session.resumeCommand }
|
||||
}
|
||||
const platform =
|
||||
args.session.executionHostId &&
|
||||
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
|
||||
args.session.executionHostPlatform
|
||||
? args.session.executionHostPlatform
|
||||
: getAiVaultResumePlatform(args.state, args.worktreeId)
|
||||
const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform)
|
||||
// Why: the queued command is typed verbatim into the freshly spawned tab whose
|
||||
// live shell is the configured Windows shell (default PowerShell). Hardcoding
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeRepoTargetStatus,
|
||||
getAiVaultResumeWorktreeTargetStatus,
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
@@ -68,6 +69,51 @@ describe('ai vault session storage compatibility', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows host-tagged SSH sessions only on the matching SSH target', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'ssh:dev-box',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: 'ssh:dev-box'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'ssh:dev-box',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: 'ssh:other-box'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'ssh:dev-box',
|
||||
targetStatus: 'local',
|
||||
targetExecutionHostId: 'local'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'ssh:dev-box',
|
||||
targetStatus: 'local'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: wslSessionFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: 'ssh:dev-box'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never allows runtime or unknown targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' })
|
||||
@@ -166,6 +212,34 @@ describe('ai vault resume target ownership', () => {
|
||||
).toBe('runtime')
|
||||
})
|
||||
|
||||
it('resolves exact execution host ids for active workspaces', () => {
|
||||
const state = makeState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1', hostId: 'ssh:ssh-1' }]
|
||||
},
|
||||
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }]
|
||||
})
|
||||
|
||||
expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'repo-1::/repo/orca')).toBe('ssh:ssh-1')
|
||||
expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'worktree:repo-1::/repo/orca')).toBe(
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves local execution host ids for local workspaces', () => {
|
||||
expect(
|
||||
getAiVaultResumeWorkspaceExecutionHostId(
|
||||
makeState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }]
|
||||
},
|
||||
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }]
|
||||
}),
|
||||
'repo-1::/repo/orca'
|
||||
)
|
||||
).toBe('local')
|
||||
})
|
||||
|
||||
it('supports folder workspaces owned by SSH project groups', () => {
|
||||
expect(
|
||||
getAiVaultResumeWorkspaceTargetStatus(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
@@ -43,11 +44,33 @@ export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | u
|
||||
|
||||
export function canResumeAiVaultSessionOnTarget(args: {
|
||||
sessionFilePath: string | null | undefined
|
||||
sessionExecutionHostId?: ExecutionHostId | null
|
||||
targetStatus: AiVaultResumeTargetStatus
|
||||
targetExecutionHostId?: ExecutionHostId | null
|
||||
}): boolean {
|
||||
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
|
||||
return false
|
||||
}
|
||||
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
|
||||
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
|
||||
if (sessionExecutionHostId) {
|
||||
if (targetExecutionHostId) {
|
||||
if (sessionExecutionHostId === targetExecutionHostId) {
|
||||
return true
|
||||
}
|
||||
// Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the
|
||||
// file lives under a WSL UNC path reachable from any SSH shell into this
|
||||
// machine, so we bypass the exact host-id match for that case.
|
||||
return (
|
||||
sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID &&
|
||||
args.targetStatus === 'ssh' &&
|
||||
isWslStoredAiVaultSessionFile(args.sessionFilePath)
|
||||
)
|
||||
}
|
||||
if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Why: vault sessions are scanned from this machine's disk (host home dirs
|
||||
// plus local WSL homes). An SSH shell can only reach the WSL-stored ones
|
||||
// (SSH-to-local-WSL setups, #6270); host-stored session files do not exist
|
||||
@@ -85,6 +108,32 @@ export function getAiVaultResumeWorktreeTargetStatus(args: {
|
||||
)
|
||||
}
|
||||
|
||||
export function getAiVaultResumeWorkspaceExecutionHostId(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
workspaceId: string | null
|
||||
): ExecutionHostId | null {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workspaceKey = parseWorkspaceKey(workspaceId)
|
||||
if (workspaceKey?.type === 'folder') {
|
||||
return getAiVaultResumeFolderExecutionHostId(state, workspaceKey.folderWorkspaceId)
|
||||
}
|
||||
|
||||
const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId
|
||||
const worktree = Object.values(state.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === worktreeId)
|
||||
const worktreeHostId = normalizeExecutionHostId(worktree?.hostId)
|
||||
if (worktreeHostId) {
|
||||
return worktreeHostId
|
||||
}
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = state.repos.find((candidate) => candidate.id === repoId)
|
||||
return repo ? getRepoExecutionHostId(repo) : null
|
||||
}
|
||||
|
||||
export function getAiVaultResumeWorkspaceTargetStatus(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
workspaceId: string | null
|
||||
@@ -136,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus(
|
||||
)
|
||||
}
|
||||
|
||||
function getAiVaultResumeFolderExecutionHostId(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos'>,
|
||||
folderWorkspaceId: string
|
||||
): ExecutionHostId | null {
|
||||
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
|
||||
const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)
|
||||
const groupHostId = normalizeExecutionHostId(group?.executionHostId)
|
||||
if (groupHostId) {
|
||||
return groupHostId
|
||||
}
|
||||
const explicitConnectionId = (workspace.connectionId ?? group?.connectionId ?? '').trim()
|
||||
if (explicitConnectionId) {
|
||||
return toSshExecutionHostId(explicitConnectionId)
|
||||
}
|
||||
return mergeAiVaultResumeExecutionHostIds(
|
||||
getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getRepoExecutionHostId)
|
||||
)
|
||||
}
|
||||
|
||||
function getAiVaultResumeExecutionHostTargetStatus(
|
||||
hostId: ExecutionHostId | null | undefined
|
||||
): AiVaultResumeTargetStatus {
|
||||
@@ -162,3 +234,13 @@ function mergeAiVaultResumeExecutionHostTargetStatuses(
|
||||
}
|
||||
return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown'
|
||||
}
|
||||
|
||||
function mergeAiVaultResumeExecutionHostIds(
|
||||
hostIds: readonly ExecutionHostId[]
|
||||
): ExecutionHostId | null {
|
||||
if (hostIds.length === 0) {
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
const uniqueHostIds = new Set(hostIds)
|
||||
return uniqueHostIds.size === 1 ? (hostIds[0] ?? null) : null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
|
||||
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
|
||||
import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session'
|
||||
export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start'
|
||||
@@ -15,6 +16,7 @@ export type AiVaultSessionDragPayload = {
|
||||
// Why: drop targets must know where the session file lives (host vs local
|
||||
// WSL) to reject SSH panes that cannot reach it.
|
||||
sessionFilePath?: string
|
||||
sessionExecutionHostId?: ExecutionHostId
|
||||
// Why: drag/drop resume must preserve planned env/default args, not just the shell command.
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
@@ -67,6 +69,8 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD
|
||||
isNonEmptyString(payload.title) &&
|
||||
isNonEmptyString(payload.command) &&
|
||||
(payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) &&
|
||||
(payload.sessionExecutionHostId === undefined ||
|
||||
Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) &&
|
||||
(payload.env === undefined || isStringRecord(payload.env)) &&
|
||||
(payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig))
|
||||
)
|
||||
@@ -114,13 +118,23 @@ export function readAiVaultSessionDragData(
|
||||
if (!isSerializedPayload(parsed)) {
|
||||
return null
|
||||
}
|
||||
const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed
|
||||
const {
|
||||
agent,
|
||||
sessionId,
|
||||
title,
|
||||
command,
|
||||
sessionFilePath,
|
||||
sessionExecutionHostId,
|
||||
env,
|
||||
launchConfig
|
||||
} = parsed
|
||||
return {
|
||||
agent,
|
||||
sessionId,
|
||||
title,
|
||||
command,
|
||||
...(sessionFilePath ? { sessionFilePath } : {}),
|
||||
...(sessionExecutionHostId ? { sessionExecutionHostId } : {}),
|
||||
...(env ? { env } : {}),
|
||||
...(launchConfig ? { launchConfig } : {})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
import type { TuiAgent } from './types'
|
||||
import type { ExecutionHostId, ExecutionHostScope } from './execution-host'
|
||||
|
||||
export const AI_VAULT_AGENTS = [
|
||||
'claude',
|
||||
@@ -53,6 +54,8 @@ export type AiVaultSessionPreviewMessage = {
|
||||
|
||||
export type AiVaultSession = {
|
||||
id: string
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
agent: AiVaultAgent
|
||||
sessionId: string
|
||||
title: string
|
||||
@@ -71,6 +74,7 @@ export type AiVaultSession = {
|
||||
}
|
||||
|
||||
export type AiVaultScanIssue = {
|
||||
executionHostId?: ExecutionHostId
|
||||
agent: AiVaultAgent
|
||||
path: string
|
||||
message: string
|
||||
@@ -82,6 +86,7 @@ export type AiVaultListArgs = {
|
||||
// Active workspace/project paths. The global result is recency-capped, so these
|
||||
// guarantee a scoped view still surfaces its own (possibly older) sessions.
|
||||
scopePaths?: readonly string[]
|
||||
executionHostScope?: ExecutionHostScope
|
||||
}
|
||||
|
||||
export type AiVaultListResult = {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver'
|
||||
import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
|
||||
test.describe('SSH Agent Session History', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.')
|
||||
|
||||
test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({
|
||||
orcaPage
|
||||
}, testInfo: TestInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
const stamp = Date.now()
|
||||
const defaultSessionId = `remote-ai-vault-${stamp}`
|
||||
const runtimeSessionId = `remote-ai-vault-runtime-${stamp}`
|
||||
const claudeSessionId = `remote-ai-vault-claude-${stamp}`
|
||||
const defaultTitle = `Remote AI Vault ${stamp}`
|
||||
const runtimeTitle = `Remote Runtime AI Vault ${stamp}`
|
||||
const claudeTitle = `Remote Claude AI Vault ${stamp}`
|
||||
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
seedRemoteAiVaultHistory(target, {
|
||||
defaultSessionId,
|
||||
runtimeSessionId,
|
||||
claudeSessionId,
|
||||
defaultTitle,
|
||||
runtimeTitle,
|
||||
claudeTitle
|
||||
})
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerRemote(orcaPage, target)
|
||||
const sshScope = `ssh:${encodeURIComponent(remote.targetId)}`
|
||||
|
||||
const scan = await orcaPage.evaluate(
|
||||
async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => {
|
||||
const local = await window.api.aiVault.listSessions({
|
||||
executionHostScope: 'local',
|
||||
force: true
|
||||
})
|
||||
const ssh = await window.api.aiVault.listSessions({
|
||||
executionHostScope: sshScope,
|
||||
force: true
|
||||
})
|
||||
const all = await window.api.aiVault.listSessions({
|
||||
executionHostScope: 'all',
|
||||
force: true
|
||||
})
|
||||
return {
|
||||
localHasRemote: local.sessions.some((session) => session.title === defaultTitle),
|
||||
sshTitles: ssh.sessions.map((session) => session.title),
|
||||
allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle),
|
||||
allHasClaude: all.sessions.some((session) => session.title === claudeTitle),
|
||||
remoteHostIds: ssh.sessions
|
||||
.filter((session) =>
|
||||
[defaultTitle, runtimeTitle, claudeTitle].includes(session.title)
|
||||
)
|
||||
.map((session) => session.executionHostId),
|
||||
remoteCommands: ssh.sessions
|
||||
.filter((session) => session.title === defaultTitle || session.title === runtimeTitle)
|
||||
.map((session) => session.resumeCommand)
|
||||
}
|
||||
},
|
||||
{ sshScope, defaultTitle, runtimeTitle, claudeTitle }
|
||||
)
|
||||
expect(scan.localHasRemote).toBe(false)
|
||||
expect(scan.sshTitles).toEqual(
|
||||
expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle])
|
||||
)
|
||||
expect(scan.allHasRuntime).toBe(true)
|
||||
expect(scan.allHasClaude).toBe(true)
|
||||
expect(new Set(scan.remoteHostIds)).toEqual(new Set([sshScope]))
|
||||
expect(scan.remoteCommands.join('\n')).toContain("CODEX_HOME='/root/.codex'")
|
||||
expect(scan.remoteCommands.join('\n')).toContain(
|
||||
"CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'"
|
||||
)
|
||||
|
||||
const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true })
|
||||
const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true })
|
||||
|
||||
await openAiVaultSidebar(orcaPage)
|
||||
await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const hostButton = orcaPage.getByRole('button', { name: /Session History host:/ })
|
||||
await hostButton.click()
|
||||
await orcaPage.getByRole('menuitemradio', { name: /Local/ }).click()
|
||||
await expect(defaultSessionTitle).toHaveCount(0, { timeout: 30_000 })
|
||||
|
||||
await hostButton.click()
|
||||
await orcaPage.getByRole('menuitemradio', { name: 'All hosts' }).click()
|
||||
await expect(runtimeSessionTitle.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await hostButton.click()
|
||||
await orcaPage
|
||||
.getByRole('menuitemradio')
|
||||
.filter({ hasNotText: /Local|All hosts/ })
|
||||
.click()
|
||||
await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await installStartupQueueProbe(orcaPage)
|
||||
await defaultSessionTitle.first().click()
|
||||
await orcaPage.getByText('Resume in Worktree', { exact: true }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 })
|
||||
.toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`)
|
||||
const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage)
|
||||
expect(queuedWorktreeId).toBe(remote.worktreeId)
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function seedRemoteAiVaultHistory(
|
||||
target: DockerSshRelayTarget,
|
||||
args: {
|
||||
defaultSessionId: string
|
||||
runtimeSessionId: string
|
||||
claudeSessionId: string
|
||||
defaultTitle: string
|
||||
runtimeTitle: string
|
||||
claudeTitle: string
|
||||
}
|
||||
): void {
|
||||
dockerExec(
|
||||
target,
|
||||
[
|
||||
'mkdir -p /root/.codex/sessions/2026/07/04',
|
||||
'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04',
|
||||
'mkdir -p /root/.claude/projects/orca'
|
||||
].join(' && ')
|
||||
)
|
||||
dockerWriteFile(
|
||||
target,
|
||||
'/root/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]),
|
||||
'600'
|
||||
)
|
||||
dockerWriteFile(
|
||||
target,
|
||||
`/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId: args.defaultSessionId,
|
||||
title: args.defaultTitle,
|
||||
cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
'600'
|
||||
)
|
||||
dockerWriteFile(
|
||||
target,
|
||||
`/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId: args.runtimeSessionId,
|
||||
title: args.runtimeTitle,
|
||||
cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
timestamp: '2026-07-04T02:00:00.000Z'
|
||||
}),
|
||||
'600'
|
||||
)
|
||||
dockerWriteFile(
|
||||
target,
|
||||
`/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`,
|
||||
claudeTranscript({
|
||||
sessionId: args.claudeSessionId,
|
||||
title: args.claudeTitle,
|
||||
timestamp: '2026-07-04T03:00:00.000Z'
|
||||
}),
|
||||
'600'
|
||||
)
|
||||
}
|
||||
|
||||
function codexTranscript(args: {
|
||||
sessionId: string
|
||||
title: string
|
||||
cwd: string
|
||||
timestamp: string
|
||||
}): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp: args.timestamp,
|
||||
type: 'session_meta',
|
||||
payload: { id: args.sessionId, cwd: args.cwd }
|
||||
},
|
||||
{
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: args.title }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string {
|
||||
return jsonLines([
|
||||
{
|
||||
sessionId: args.sessionId,
|
||||
timestamp: args.timestamp,
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: args.title }] }
|
||||
},
|
||||
{
|
||||
sessionId: args.sessionId,
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'assistant',
|
||||
message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
async function openAiVaultSidebar(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
store.getState().setRightSidebarOpen(true)
|
||||
store.getState().setRightSidebarTab('vault')
|
||||
})
|
||||
}
|
||||
|
||||
async function installStartupQueueProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const holder = window as unknown as {
|
||||
__aiVaultQueuedStartups?: { tabId: string; startup: { command: string } }[]
|
||||
}
|
||||
holder.__aiVaultQueuedStartups = []
|
||||
const current = store.getState()
|
||||
const original = current.queueTabStartupCommand
|
||||
store.setState({
|
||||
queueTabStartupCommand: (tabId, startup) => {
|
||||
holder.__aiVaultQueuedStartups?.push({ tabId, startup: { command: startup.command } })
|
||||
original(tabId, startup)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readLastQueuedStartupCommand(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const holder = window as unknown as {
|
||||
__aiVaultQueuedStartups?: { startup: { command: string } }[]
|
||||
}
|
||||
return holder.__aiVaultQueuedStartups?.at(-1)?.startup.command ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function readLastQueuedStartupWorktreeId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const holder = window as unknown as {
|
||||
__aiVaultQueuedStartups?: { tabId: string }[]
|
||||
}
|
||||
const tabId = holder.__aiVaultQueuedStartups?.at(-1)?.tabId
|
||||
if (!tabId) {
|
||||
return null
|
||||
}
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
|
||||
if (tabs.some((tab) => tab.id === tabId)) {
|
||||
return worktreeId
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user