mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<Worktree, 'repoId' | 'worktreeId'> & {
|
||||
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<string> {
|
||||
const ids = new Set<string>([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'
|
||||
}
|
||||
|
||||
@@ -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<Worktree, 'repoId' | 'worktreeId'> & {
|
||||
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<string> {
|
||||
const ids = new Set<string>([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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user