From 0c8303c210d4965af50eed98192da603d480554a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:03 -0700 Subject: [PATCH] fix(mobile): Agent Session History resumes sessions in SSH workspaces that can read them (#29461) * fix(mobile): resume Agent Session History rows in an SSH workspace that can read them * fix(mobile): keep transcript-referencing resumes on the transcript's own host --- .../agent-history-resume-target.test.ts | 91 +++++- .../agent-history-resume-target.ts | 263 ++++------------- .../agent-history-resume-workspace-host.ts | 266 ++++++++++++++++++ .../src/lib/ai-vault-resume-target.ts | 41 +-- src/shared/ai-vault-ssh-resume-target.test.ts | 87 ++++++ src/shared/ai-vault-ssh-resume-target.ts | 39 +++ 6 files changed, 539 insertions(+), 248 deletions(-) create mode 100644 mobile/src/agent-history/agent-history-resume-workspace-host.ts create mode 100644 src/shared/ai-vault-ssh-resume-target.test.ts create mode 100644 src/shared/ai-vault-ssh-resume-target.ts diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index 925e881aac0..32ebf1e3a09 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -70,6 +70,91 @@ describe('mobile AI Vault resume target guards', () => { { id: 'group-runtime', executionHostId: 'runtime:devbox' as const } ] + const WSL_TRANSCRIPT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\app\\s.jsonl' + const sshSessionWorktree = worktree({ + worktreeId: 'ssh-session-wt', + repoId: 'ssh-repo', + path: '/home/ada/ssh-repo/feature' + }) + + it('resumes a WSL-stored session in an SSH workspace into that distro (#26818)', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/ssh-repo/feature/src', filePath: WSL_TRANSCRIPT }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [sshSessionWorktree], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'ssh-session-wt', + targetStatus: 'ssh', + workspacePath: '/home/ada/ssh-repo/feature', + terminalPlatform: null + }) + }) + + it('resumes a session scanned on the same SSH host in that host workspace', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ + executionHostId: 'ssh:builder', + cwd: '/home/ada/ssh-repo/feature/src', + filePath: '/home/ada/.claude/projects/app/s.jsonl' + }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [sshSessionWorktree], + repos + }) + expect(target.status === 'ready' ? target.targetStatus : target.message).toBe('ssh') + }) + + it('blocks a session scanned on another SSH host', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ + executionHostId: 'ssh:other', + cwd: '/home/ada/ssh-repo/feature/src', + filePath: '/home/ada/.claude/projects/app/s.jsonl' + }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [sshSessionWorktree], + repos + }) + expect(target.status).toBe('blocked') + }) + + it('blocks a WSL Antigravity IDE transcript in an SSH workspace', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ + agent: 'antigravity', + cwd: '/home/ada/ssh-repo/feature/src', + filePath: + '//wsl.localhost/Ubuntu/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl' + }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [sshSessionWorktree], + repos + }) + expect(target.status).toBe('blocked') + }) + + it('resumes a WSL-stored session in an SSH folder workspace', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/folder/src', filePath: WSL_TRANSCRIPT }), + activeWorktreeId: 'folder:folder-ssh', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-ssh', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/home/ada/folder' + }) + ], + repos, + folderWorkspaces, + projectGroups + }) + expect(target.status === 'ready' ? target.targetStatus : target.message).toBe('ssh') + }) + it('resolves project-scope rows to the matched session worktree before the route worktree', () => { const target = resolveMobileAiVaultSessionResumeTarget({ session: session({ cwd: '/Users/ada/repo/feature/src' }), @@ -125,7 +210,7 @@ describe('mobile AI Vault resume target guards', () => { expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted') }) - it('blocks SSH folder workspace targets because transcripts are host-local', () => { + it('blocks SSH folder workspace targets for a host-local transcript', () => { const target = resolveMobileAiVaultSessionResumeTarget({ session: session({ cwd: '/home/ada/folder/src' }), activeWorktreeId: 'folder:folder-ssh', @@ -142,7 +227,7 @@ describe('mobile AI Vault resume target guards', () => { projectGroups }) expect(target.status).toBe('blocked') - expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + expect(target.status === 'blocked' ? target.message : '').toContain('different host') }) it('skips an SSH session-worktree candidate in favor of a local active-worktree fallback', () => { @@ -182,7 +267,7 @@ describe('mobile AI Vault resume target guards', () => { repos }) expect(target.status).toBe('blocked') - expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + expect(target.status === 'blocked' ? target.message : '').toContain('different host') }) it('blocks folder workspaces whose candidate repos include a runtime owner', () => { diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts index d873a9df58f..0f6e921f45d 100644 --- a/mobile/src/agent-history/agent-history-resume-target.ts +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -1,113 +1,62 @@ -import { - getRepoExecutionHostId, - normalizeExecutionHostId, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../src/shared/execution-host' +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' -import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import { canResumeAiVaultSessionOnSshHost } from '../../../src/shared/ai-vault-ssh-resume-target' import type { Worktree } from '../worktree/workspace-list-types' +import { + getMobileAiVaultResumeWorktreeExecutionHostId, + getMobileAiVaultResumeWorktreeTargetStatus, + type MobileAiVaultResumeFolderWorkspace, + type MobileAiVaultResumeProjectGroup, + type MobileAiVaultResumeRepo, + type MobileAiVaultResumeTargetStatus +} from './agent-history-resume-workspace-host' import { canResumeInMobileSessionWorktree, resolveMobileAgentHistorySessionWorktree } from './agent-history-session-worktree' -export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' - -export type MobileAiVaultResumeRepo = { - id: string - path?: string | null - projectGroupId?: string | null - connectionId?: string | null - executionHostId?: ExecutionHostId | null -} - -type MobileAiVaultResumeWorktree = Pick & { - path?: string | null - workspaceKind?: Worktree['workspaceKind'] - hostId?: ExecutionHostId | null -} - -export type MobileAiVaultResumeFolderWorkspace = { - id: string - projectGroupId: string - folderPath: string - connectionId?: string | null -} - -export type MobileAiVaultResumeProjectGroup = { - id: string - parentGroupId?: string | null - connectionId?: string | null - executionHostId?: ExecutionHostId | string | null +export type { + MobileAiVaultResumeFolderWorkspace, + MobileAiVaultResumeProjectGroup, + MobileAiVaultResumeRepo, + MobileAiVaultResumeTargetStatus } export type MobileAiVaultSessionResumeTarget = | { status: 'ready' worktreeId: string - targetStatus: 'local' + targetStatus: 'local' | 'ssh' workspacePath: string | null terminalPlatform: NodeJS.Platform | null } | { status: 'blocked'; message: string } -function getMobileAiVaultResumeRepoTargetStatus( - repo: MobileAiVaultResumeRepo | null | undefined -): MobileAiVaultResumeTargetStatus { - if (!repo) { - return 'unknown' +function canResumeMobileAiVaultSessionOnTarget(args: { + session: AiVaultSession + targetStatus: MobileAiVaultResumeTargetStatus + targetExecutionHostId: ExecutionHostId | null +}): args is typeof args & { targetStatus: 'local' | 'ssh' } { + if (args.targetStatus === 'local') { + return true } - return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo)) -} - -function getMobileAiVaultResumeWorktreeTargetStatus(args: { - worktreeId: string | null - worktrees: readonly MobileAiVaultResumeWorktree[] - repos: readonly MobileAiVaultResumeRepo[] - folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] - projectGroups?: readonly MobileAiVaultResumeProjectGroup[] -}): MobileAiVaultResumeTargetStatus { - if (!args.worktreeId) { - return 'unknown' - } - const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId) - if (!worktree) { - return 'unknown' - } - if (worktree.workspaceKind === 'folder-workspace') { - return getMobileAiVaultResumeFolderTargetStatus({ - worktreeId: args.worktreeId, - worktree, - repos: args.repos, - folderWorkspaces: args.folderWorkspaces ?? [], - projectGroups: args.projectGroups ?? [] + // Why: same host rule as desktop: an SSH workspace resumes only transcripts that host can read. + return ( + args.targetStatus === 'ssh' && + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: args.session.filePath, + sessionExecutionHostId: args.session.executionHostId, + targetExecutionHostId: args.targetExecutionHostId }) - } - const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId) - if (worktreeHost !== 'unknown') { - return worktreeHost - } - return getMobileAiVaultResumeRepoTargetStatus( - args.repos.find((candidate) => candidate.id === worktree.repoId) ) } -function isSupportedMobileAiVaultResumeTargetStatus( - status: MobileAiVaultResumeTargetStatus -): status is 'local' { - // Why: mobile sessions come from the host-local transcript scan, so an SSH - // workspace cannot see the transcript file (mirror of desktop #6270). - return status === 'local' -} - function mobileAiVaultResumeTargetBlockMessage(status: MobileAiVaultResumeTargetStatus): string { if (status === 'runtime') { return 'Resume from history is not available in runtime-hosted workspaces.' } if (status === 'ssh') { - return 'This session is stored on the host machine, so it cannot be resumed in an SSH workspace. Open a local workspace for this project.' + return 'This session belongs to a different host. Open a workspace on the same host to resume it.' } return 'Open a local workspace before resuming a session.' } @@ -143,13 +92,27 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { folderWorkspaces: args.folderWorkspaces, projectGroups: args.projectGroups }) - if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) { + const candidate = { + session: args.session, + targetStatus, + targetExecutionHostId: + targetStatus === 'ssh' + ? getMobileAiVaultResumeWorktreeExecutionHostId({ + worktreeId: candidateWorktreeId, + worktrees: args.worktrees, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces ?? [], + projectGroups: args.projectGroups ?? [] + }) + : null + } + if (!canResumeMobileAiVaultSessionOnTarget(candidate)) { continue } return { status: 'ready', worktreeId: candidateWorktreeId, - targetStatus, + targetStatus: candidate.targetStatus, workspacePath: args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ?? null, @@ -168,135 +131,3 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { }) return { status: 'blocked', message: mobileAiVaultResumeTargetBlockMessage(blockedStatus) } } - -function getMobileAiVaultResumeFolderTargetStatus(args: { - worktreeId: string - worktree: MobileAiVaultResumeWorktree - repos: readonly MobileAiVaultResumeRepo[] - folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[] - projectGroups: readonly MobileAiVaultResumeProjectGroup[] -}): MobileAiVaultResumeTargetStatus { - const folderWorkspaceId = args.worktreeId.startsWith('folder:') - ? args.worktreeId.slice('folder:'.length) - : null - const folderWorkspace = folderWorkspaceId - ? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId) - : null - if (!folderWorkspace) { - return 'unknown' - } - const projectGroupId = - folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId) - const projectGroup = projectGroupId - ? args.projectGroups.find((group) => group.id === projectGroupId) - : null - - const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId) - if (groupHostId) { - return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId) - } - - const explicitConnectionId = ( - folderWorkspace?.connectionId ?? - projectGroup?.connectionId ?? - '' - ).trim() - if (explicitConnectionId) { - return getMobileAiVaultResumeExecutionHostTargetStatus( - toSshExecutionHostId(explicitConnectionId) - ) - } - - return mergeMobileAiVaultResumeExecutionHostTargetStatuses( - getMobileFolderWorkspaceCandidateRepos({ - folderWorkspace, - projectGroupId, - projectGroups: args.projectGroups, - repos: args.repos - }).map(getRepoExecutionHostId) - ) -} - -function getMobileAiVaultResumeExecutionHostTargetStatus( - hostId: ExecutionHostId | null | undefined -): MobileAiVaultResumeTargetStatus { - const parsed = parseExecutionHostId(hostId) - if (!parsed) { - return 'unknown' - } - return parsed.kind -} - -function parseFolderWorkspaceRepoId(repoId: string): string | null { - const prefix = 'folder-workspace:' - return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null -} - -function getMobileFolderWorkspaceCandidateRepos(args: { - folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined - projectGroupId: string | null - projectGroups: readonly MobileAiVaultResumeProjectGroup[] - repos: readonly MobileAiVaultResumeRepo[] -}): MobileAiVaultResumeRepo[] { - if (!args.folderWorkspace || !args.projectGroupId) { - return [] - } - const folderWorkspace = args.folderWorkspace - const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) - const groupRepos = args.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = args.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - typeof repo.path === 'string' && - repo.path.trim().length > 0 && - isPathInsideOrEqual(folderWorkspace.folderPath, repo.path) - ) - if (folderWorkspace.connectionId) { - return [ - ...groupRepos, - ...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId) - ] - } - if (groupRepos.length === 0) { - return pathRepos - } - const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) - return [ - ...groupRepos, - ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) - ] -} - -function getMobileProjectGroupSubtreeIds( - projectGroups: readonly MobileAiVaultResumeProjectGroup[], - projectGroupId: string -): Set { - const ids = new Set([projectGroupId]) - let changed = true - while (changed) { - changed = false - for (const group of projectGroups) { - if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) { - ids.add(group.id) - changed = true - } - } - } - return ids -} - -function mergeMobileAiVaultResumeExecutionHostTargetStatuses( - hostIds: readonly ExecutionHostId[] -): MobileAiVaultResumeTargetStatus { - if (hostIds.length === 0) { - return 'local' - } - const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus) - const uniqueStatuses = new Set(statuses) - if (uniqueStatuses.has('runtime')) { - return 'runtime' - } - return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' -} diff --git a/mobile/src/agent-history/agent-history-resume-workspace-host.ts b/mobile/src/agent-history/agent-history-resume-workspace-host.ts new file mode 100644 index 00000000000..54cbe22d814 --- /dev/null +++ b/mobile/src/agent-history/agent-history-resume-workspace-host.ts @@ -0,0 +1,266 @@ +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../src/shared/execution-host' +import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import type { Worktree } from '../worktree/workspace-list-types' + +// Which execution host a mobile resume target workspace runs on. + +export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' + +export type MobileAiVaultResumeRepo = { + id: string + path?: string | null + projectGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +export type MobileAiVaultResumeWorktree = Pick & { + path?: string | null + workspaceKind?: Worktree['workspaceKind'] + hostId?: ExecutionHostId | null +} + +export type MobileAiVaultResumeFolderWorkspace = { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null +} + +export type MobileAiVaultResumeProjectGroup = { + id: string + parentGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | string | null +} + +function getMobileAiVaultResumeRepoTargetStatus( + repo: MobileAiVaultResumeRepo | null | undefined +): MobileAiVaultResumeTargetStatus { + if (!repo) { + return 'unknown' + } + return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo)) +} + +export function getMobileAiVaultResumeWorktreeTargetStatus(args: { + worktreeId: string | null + worktrees: readonly MobileAiVaultResumeWorktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups?: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + if (!args.worktreeId) { + return 'unknown' + } + const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId) + if (!worktree) { + return 'unknown' + } + if (worktree.workspaceKind === 'folder-workspace') { + return getMobileAiVaultResumeFolderTargetStatus({ + worktreeId: args.worktreeId, + worktree, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces ?? [], + projectGroups: args.projectGroups ?? [] + }) + } + const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId) + if (worktreeHost !== 'unknown') { + return worktreeHost + } + return getMobileAiVaultResumeRepoTargetStatus( + args.repos.find((candidate) => candidate.id === worktree.repoId) + ) +} + +/** The SSH workspace's own host, so a resume only targets the host holding the transcript. */ +export function getMobileAiVaultResumeWorktreeExecutionHostId(args: { + worktreeId: string + worktrees: readonly MobileAiVaultResumeWorktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups: readonly MobileAiVaultResumeProjectGroup[] +}): ExecutionHostId | null { + const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId) + if (!worktree) { + return null + } + if (worktree.workspaceKind !== 'folder-workspace') { + const repo = args.repos.find((candidate) => candidate.id === worktree.repoId) + return normalizeExecutionHostId(worktree.hostId) ?? (repo ? getRepoExecutionHostId(repo) : null) + } + const folderWorkspace = args.worktreeId.startsWith('folder:') + ? args.folderWorkspaces.find( + (workspace) => workspace.id === args.worktreeId.slice('folder:'.length) + ) + : undefined + if (!folderWorkspace) { + return null + } + const projectGroupId = + folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(worktree.repoId) + const projectGroup = projectGroupId + ? args.projectGroups.find((group) => group.id === projectGroupId) + : null + const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId) + if (groupHostId) { + return groupHostId + } + const explicitConnectionId = ( + folderWorkspace.connectionId ?? + projectGroup?.connectionId ?? + '' + ).trim() + if (explicitConnectionId) { + return toSshExecutionHostId(explicitConnectionId) + } + const hostIds = new Set( + getMobileFolderWorkspaceCandidateRepos({ + folderWorkspace, + projectGroupId, + projectGroups: args.projectGroups, + repos: args.repos + }).map(getRepoExecutionHostId) + ) + return hostIds.size === 1 ? ([...hostIds][0] ?? null) : null +} + +function getMobileAiVaultResumeFolderTargetStatus(args: { + worktreeId: string + worktree: MobileAiVaultResumeWorktree + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + const folderWorkspaceId = args.worktreeId.startsWith('folder:') + ? args.worktreeId.slice('folder:'.length) + : null + const folderWorkspace = folderWorkspaceId + ? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId) + : null + if (!folderWorkspace) { + return 'unknown' + } + const projectGroupId = + folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId) + const projectGroup = projectGroupId + ? args.projectGroups.find((group) => group.id === projectGroupId) + : null + + const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId) + if (groupHostId) { + return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId) + } + + const explicitConnectionId = ( + folderWorkspace?.connectionId ?? + projectGroup?.connectionId ?? + '' + ).trim() + if (explicitConnectionId) { + return getMobileAiVaultResumeExecutionHostTargetStatus( + toSshExecutionHostId(explicitConnectionId) + ) + } + + return mergeMobileAiVaultResumeExecutionHostTargetStatuses( + getMobileFolderWorkspaceCandidateRepos({ + folderWorkspace, + projectGroupId, + projectGroups: args.projectGroups, + repos: args.repos + }).map(getRepoExecutionHostId) + ) +} + +function getMobileAiVaultResumeExecutionHostTargetStatus( + hostId: ExecutionHostId | null | undefined +): MobileAiVaultResumeTargetStatus { + const parsed = parseExecutionHostId(hostId) + if (!parsed) { + return 'unknown' + } + return parsed.kind +} + +function parseFolderWorkspaceRepoId(repoId: string): string | null { + const prefix = 'folder-workspace:' + return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null +} + +function getMobileFolderWorkspaceCandidateRepos(args: { + folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined + projectGroupId: string | null + projectGroups: readonly MobileAiVaultResumeProjectGroup[] + repos: readonly MobileAiVaultResumeRepo[] +}): MobileAiVaultResumeRepo[] { + if (!args.folderWorkspace || !args.projectGroupId) { + return [] + } + const folderWorkspace = args.folderWorkspace + const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + typeof repo.path === 'string' && + repo.path.trim().length > 0 && + isPathInsideOrEqual(folderWorkspace.folderPath, repo.path) + ) + if (folderWorkspace.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) + ] +} + +function getMobileProjectGroupSubtreeIds( + projectGroups: readonly MobileAiVaultResumeProjectGroup[], + projectGroupId: string +): Set { + const ids = new Set([projectGroupId]) + let changed = true + while (changed) { + changed = false + for (const group of projectGroups) { + if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) { + ids.add(group.id) + changed = true + } + } + } + return ids +} + +function mergeMobileAiVaultResumeExecutionHostTargetStatuses( + hostIds: readonly ExecutionHostId[] +): MobileAiVaultResumeTargetStatus { + if (hostIds.length === 0) { + return 'local' + } + const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus) + const uniqueStatuses = new Set(statuses) + if (uniqueStatuses.has('runtime')) { + return 'runtime' + } + return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' +} diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 38dbdabaee1..6a52f0bba00 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -11,6 +11,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths' import { antigravitySessionOrigin } from '../../../shared/antigravity-session-origin' +import { canResumeAiVaultSessionOnSshHost } from '../../../shared/ai-vault-ssh-resume-target' import type { AppState } from '@/store/types' import { getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -85,38 +86,20 @@ export function canResumeAiVaultSessionOnTarget(args: { if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { return false } - if ( - args.targetStatus === 'local' && - !isAiVaultSessionInWslNamespace(args.sessionFilePath, args.targetWslDistro) - ) { + if (args.targetStatus === 'ssh') { + return canResumeAiVaultSessionOnSshHost({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId, + targetExecutionHostId + }) + } + if (!isAiVaultSessionInWslNamespace(args.sessionFilePath, args.targetWslDistro)) { return false } - 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 - } + if (sessionExecutionHostId && targetExecutionHostId) { + return sessionExecutionHostId === targetExecutionHostId } - // 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 - // on a remote filesystem, so queuing a resume there is guaranteed to fail. - if (args.targetStatus === 'ssh') { - return isWslStoredAiVaultSessionFile(args.sessionFilePath) - } - return true + return !sessionExecutionHostId || sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID } /** diff --git a/src/shared/ai-vault-ssh-resume-target.test.ts b/src/shared/ai-vault-ssh-resume-target.test.ts new file mode 100644 index 00000000000..7aa97d52183 --- /dev/null +++ b/src/shared/ai-vault-ssh-resume-target.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { canResumeAiVaultSessionOnSshHost } from './ai-vault-ssh-resume-target' + +const WSL_FILE = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\app\\s.jsonl' +const HOST_FILE = '/Users/ada/.claude/projects/app/s.jsonl' + +describe('canResumeAiVaultSessionOnSshHost', () => { + it('accepts a session scanned on the target SSH host', () => { + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: '/home/ada/.claude/projects/app/s.jsonl', + sessionExecutionHostId: 'ssh:builder', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(true) + }) + + it('refuses a session scanned on another SSH host', () => { + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: WSL_FILE, + sessionExecutionHostId: 'ssh:other', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(false) + }) + + it('accepts a local WSL-stored transcript, with or without a stamped host', () => { + for (const sessionExecutionHostId of ['local' as const, null]) { + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: WSL_FILE, + sessionExecutionHostId, + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(true) + } + }) + + it('refuses a transcript stored on this machine outside WSL', () => { + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: HOST_FILE, + sessionExecutionHostId: 'local', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(false) + }) + + it('keeps a transcript-referencing Antigravity resume on the transcript host', () => { + const wslReference = + '//wsl.localhost/Ubuntu/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl' + const sshReference = '/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl' + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: wslReference, + sessionExecutionHostId: 'local', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: sshReference, + sessionExecutionHostId: 'ssh:builder', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(true) + // The CLI resumes by ID, so it keeps the WSL exception. + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: wslReference.replace('antigravity-ide', 'antigravity-cli'), + sessionExecutionHostId: 'local', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(true) + }) + + it('refuses a runtime-scanned session', () => { + expect( + canResumeAiVaultSessionOnSshHost({ + sessionFilePath: WSL_FILE, + sessionExecutionHostId: 'runtime:devbox', + targetExecutionHostId: 'ssh:builder' + }) + ).toBe(false) + }) +}) diff --git a/src/shared/ai-vault-ssh-resume-target.ts b/src/shared/ai-vault-ssh-resume-target.ts new file mode 100644 index 00000000000..281ce4391a8 --- /dev/null +++ b/src/shared/ai-vault-ssh-resume-target.ts @@ -0,0 +1,39 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from './execution-host' +import { isWslUncPath } from './wsl-paths' +import { antigravitySessionOrigin } from './antigravity-session-origin' + +/** + * Whether a history session can resume in an SSH workspace on `targetExecutionHostId`. + * Shared by desktop and mobile so both apply the same host rule. + */ +export function canResumeAiVaultSessionOnSshHost(args: { + sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null + targetExecutionHostId?: ExecutionHostId | null +}): boolean { + const sessionHost = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetHost = normalizeExecutionHostId(args.targetExecutionHostId) + const origin = args.sessionFilePath ? antigravitySessionOrigin(args.sessionFilePath) : null + if (origin && origin !== 'antigravity-cli') { + // Why: these resumes reference the transcript file itself, so only its own host can open it; + // the WSL exception below holds only for ID-based resumes. + return ( + (sessionHost ?? LOCAL_EXECUTION_HOST_ID) === targetHost && + !isWslUncPath(args.sessionFilePath ?? '') + ) + } + if (sessionHost && targetHost && sessionHost === targetHost) { + return true + } + // Why: SSH-to-local-WSL setups (#6270) scan the WSL home from this machine, so the row is + // stamped local, but its WSL UNC transcript is what the SSH shell into that distro reads. + // Any other host-local transcript does not exist on the remote filesystem. + return ( + (sessionHost ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID && + Boolean(args.sessionFilePath && isWslUncPath(args.sessionFilePath)) + ) +}