mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
feat(ai-vault): add cursor command cache with host isolation
Cursor command detection is expensive—spawning a shell process or making an SSH RPC. Cache detected commands with a 30-second TTL and keep entries separate per host (SSH connection, WSL distro, local) to avoid repeated probes and ensure each host resolves correctly. Thread wslDistro through mobile resume targets so session resumption identifies the right host.
This commit is contained in:
@@ -36,7 +36,6 @@ import {
|
||||
import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card'
|
||||
import { styles } from './agent-history-styles'
|
||||
import { resolveRequiredMobileCursorResumeCommand } from '../session/mobile-cursor-command'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { useMobileAgentHistoryWorktrees } from './use-mobile-agent-history-worktrees'
|
||||
|
||||
export type MobileAgentSessionHistoryPanelProps = {
|
||||
@@ -172,13 +171,10 @@ export function MobileAgentSessionHistoryPanel({
|
||||
}
|
||||
|
||||
const preparedSession = await prepareMobileAiVaultSessionResume(client, session)
|
||||
const resumeWorktrees = freshWorktrees ?? worktrees
|
||||
const cursorCommand = await resolveRequiredMobileCursorResumeCommand({
|
||||
client,
|
||||
settings,
|
||||
wslDistro:
|
||||
resumeWorktrees.find((worktree) => worktree.worktreeId === target.worktreeId)
|
||||
?.wslDistro ?? parseWslUncPath(target.workspacePath ?? '')?.distro,
|
||||
wslDistro: target.wslDistro,
|
||||
session
|
||||
})
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
|
||||
@@ -175,7 +175,8 @@ describe('mobile AI Vault resume target guards', () => {
|
||||
worktreeId: 'session-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/feature',
|
||||
terminalPlatform: null
|
||||
terminalPlatform: null,
|
||||
wslDistro: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +199,8 @@ describe('mobile AI Vault resume target guards', () => {
|
||||
worktreeId: 'route-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/main',
|
||||
terminalPlatform: null
|
||||
terminalPlatform: null,
|
||||
wslDistro: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -274,7 +276,8 @@ describe('mobile AI Vault resume target guards', () => {
|
||||
worktreeId: 'route-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/main',
|
||||
terminalPlatform: null
|
||||
terminalPlatform: null,
|
||||
wslDistro: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export type MobileAiVaultSessionResumeTarget =
|
||||
targetStatus: 'local'
|
||||
workspacePath: string | null
|
||||
terminalPlatform: NodeJS.Platform | null
|
||||
wslDistro: string | null
|
||||
}
|
||||
| { status: 'blocked'; message: string }
|
||||
|
||||
@@ -155,6 +156,8 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
|
||||
(worktree) => worktree.worktreeId === candidateWorktreeId
|
||||
)
|
||||
const targetRepo = args.repos.find((repo) => repo.id === targetWorktree?.repoId)
|
||||
const targetWslDistro =
|
||||
targetWorktree?.wslDistro ?? parseWslUncPath(targetWorktree?.path ?? '')?.distro ?? null
|
||||
if (
|
||||
!canResumeAiVaultSessionInExecutionContext({
|
||||
agent: args.session.agent,
|
||||
@@ -164,8 +167,7 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
|
||||
targetExecutionHostId:
|
||||
normalizeExecutionHostId(targetWorktree?.hostId) ??
|
||||
(targetRepo ? getRepoExecutionHostId(targetRepo) : LOCAL_EXECUTION_HOST_ID),
|
||||
targetWslDistro:
|
||||
targetWorktree?.wslDistro ?? parseWslUncPath(targetWorktree?.path ?? '')?.distro ?? null
|
||||
targetWslDistro
|
||||
})
|
||||
) {
|
||||
continue
|
||||
@@ -175,7 +177,8 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
|
||||
worktreeId: candidateWorktreeId,
|
||||
targetStatus,
|
||||
workspacePath: targetWorktree?.path ?? null,
|
||||
terminalPlatform: targetWorktree?.terminalPlatform ?? null
|
||||
terminalPlatform: targetWorktree?.terminalPlatform ?? null,
|
||||
wslDistro: targetWslDistro
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,11 +243,7 @@ function getMobileAiVaultResumeFolderTargetStatus(args: {
|
||||
function getMobileAiVaultResumeExecutionHostTargetStatus(
|
||||
hostId: ExecutionHostId | null | undefined
|
||||
): MobileAiVaultResumeTargetStatus {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (!parsed) {
|
||||
return 'unknown'
|
||||
}
|
||||
return parsed.kind
|
||||
return parseExecutionHostId(hostId)?.kind ?? 'unknown'
|
||||
}
|
||||
|
||||
function parseFolderWorkspaceRepoId(repoId: string): string | null {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from '../session/ai-vault-resume-preparation'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
@@ -19,7 +20,11 @@ export function useMobileAgentHistoryWorktrees(
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.ps', { limit: 10000 })
|
||||
const response = await client.sendRequest(
|
||||
'worktree.ps',
|
||||
{ limit: 10000 },
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!cancelled && response.ok) {
|
||||
setSnapshot({
|
||||
client,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
|
||||
import { resolveMobileCursorCommand } from './mobile-cursor-command'
|
||||
|
||||
describe('resolveMobileCursorCommand', () => {
|
||||
@@ -15,7 +16,9 @@ describe('resolveMobileCursorCommand', () => {
|
||||
await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBe(
|
||||
'cursor agent'
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', undefined)
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', undefined, {
|
||||
timeoutMs: RESUME_RPC_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed without a valid inventory unless an override exists', async () => {
|
||||
@@ -47,8 +50,10 @@ describe('resolveMobileCursorCommand', () => {
|
||||
wslDistro: ' Ubuntu '
|
||||
})
|
||||
).resolves.toBe('cursor agent')
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'preflight.detectAgentInventory',
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveEffectiveCursorCommand } from '../../../src/shared/cursor-command'
|
||||
import { detectedAgentInventorySchema } from '../../../src/shared/detected-agent-inventory'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
|
||||
import type { MobileAiVaultResumeSettings } from './ai-vault-resume-launch'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
|
||||
@@ -16,7 +17,8 @@ export async function resolveMobileCursorCommand(args: {
|
||||
const wslDistro = args.wslDistro?.trim()
|
||||
const response = await args.client.sendRequest(
|
||||
'preflight.detectAgentInventory',
|
||||
wslDistro ? { wslDistro } : undefined
|
||||
wslDistro ? { wslDistro } : undefined,
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return null
|
||||
|
||||
@@ -46,7 +46,15 @@ export async function listCachedRemoteAiVaultSessions(args: {
|
||||
const now = Date.now()
|
||||
if (existing?.inflight) {
|
||||
existing.lastUsedAt = now
|
||||
return withScopePathTruncationIssue(await existing.inflight, args, scopePathsTruncated)
|
||||
try {
|
||||
return withScopePathTruncationIssue(await existing.inflight, args, scopePathsTruncated)
|
||||
} catch (error) {
|
||||
// The shared scan carries the initiating caller's abort semantics; re-issue our own
|
||||
// scan rather than inheriting an abort this caller never requested.
|
||||
if (args.signal?.aborted !== false) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (args.force !== true && existing?.result && existing.expiresAt > now) {
|
||||
existing.lastUsedAt = now
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('Cursor session scanner integration', () => {
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const scopePath = join(root, 'scoped-workspace')
|
||||
const otherPaths = [join(root, 'newest-workspace'), join(root, 'middle-workspace')]
|
||||
// Timestamps derive from array index, so the newest bucket must sort last.
|
||||
const otherPaths = [join(root, 'middle-workspace'), join(root, 'newest-workspace')]
|
||||
const sessionId = 'shared-session-id'
|
||||
await mkdir(scopePath, { recursive: true })
|
||||
|
||||
|
||||
@@ -43,14 +43,18 @@ export async function processLocalCursorCandidates(args: {
|
||||
const parsed: ParsedCursorCandidate[] = []
|
||||
const processedGroups = new Set<CursorCandidateSelectionGroup<SessionFileCandidate>>()
|
||||
for (let index = 0; index < groups.length; index += CURSOR_PARSE_CONCURRENCY) {
|
||||
const preview = reconcileCursorCandidates({
|
||||
candidates: parsed,
|
||||
executionHostId: args.executionHostId,
|
||||
platform: args.platform,
|
||||
issues: []
|
||||
})
|
||||
if (canStopCursorGroupSelection(preview.sessions, args.limit, groups[index]?.mtimeMs)) {
|
||||
break
|
||||
// Reconcile is O(parsed); sessions never outnumber candidates, so a short parsed
|
||||
// count can't satisfy the limit and the preview would be wasted work.
|
||||
if (parsed.length >= args.limit) {
|
||||
const preview = reconcileCursorCandidates({
|
||||
candidates: parsed,
|
||||
executionHostId: args.executionHostId,
|
||||
platform: args.platform,
|
||||
issues: []
|
||||
})
|
||||
if (canStopCursorGroupSelection(preview.sessions, args.limit, groups[index]?.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const batch = groups.slice(index, index + CURSOR_PARSE_CONCURRENCY)
|
||||
await parseCursorGroups(batch, parsed, args)
|
||||
|
||||
@@ -132,9 +132,13 @@ export async function detectRemoteAgentCommands(args: {
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return legacyDetectedAgentInventory(
|
||||
(await detectRemoteAgents(args)) as DetectedTuiAgentsResult['agents']
|
||||
)
|
||||
try {
|
||||
return legacyDetectedAgentInventory(
|
||||
(await detectRemoteAgents(args)) as DetectedTuiAgentsResult['agents']
|
||||
)
|
||||
} catch {
|
||||
return emptyDetectedAgentInventory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19104,16 +19104,25 @@ export class OrcaRuntimeService {
|
||||
return getAgentLaunchPlatformForRepo(repo, projectRuntime)
|
||||
}
|
||||
|
||||
private resolveProjectRuntimeWslDistro(
|
||||
resolution: ProjectExecutionRuntimeResolution | undefined
|
||||
): string | null {
|
||||
const runtime =
|
||||
resolution?.status === 'resolved' ? resolution.runtime : resolution?.repair.preferredRuntime
|
||||
return runtime?.kind === 'wsl' ? runtime.distro?.trim() || null : null
|
||||
}
|
||||
|
||||
private getAgentLaunchWslDistroForRepo(repo: Repo): string | null {
|
||||
if (repo.connectionId) {
|
||||
return null
|
||||
}
|
||||
const projectRuntime = resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
const runtime =
|
||||
projectRuntime?.status === 'resolved'
|
||||
? projectRuntime.runtime
|
||||
: projectRuntime?.repair.preferredRuntime
|
||||
return runtime?.kind === 'wsl' ? runtime.distro : (parseWslUncPath(repo.path)?.distro ?? null)
|
||||
return (
|
||||
this.resolveProjectRuntimeWslDistro(
|
||||
resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
) ??
|
||||
parseWslUncPath(repo.path)?.distro ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private getAgentLaunchWslDistroForWorkspace(scope: TerminalWorkspaceLaunchScope): string | null {
|
||||
@@ -28614,12 +28623,7 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
const wslDistroByRepoId = new Map(
|
||||
repos.flatMap((repo): [string, string][] => {
|
||||
const resolution = projectRuntimeByRepoId.get(repo.id)
|
||||
const runtime =
|
||||
resolution?.status === 'resolved'
|
||||
? resolution.runtime
|
||||
: resolution?.repair.preferredRuntime
|
||||
const distro = runtime?.kind === 'wsl' ? runtime.distro?.trim() : null
|
||||
const distro = this.resolveProjectRuntimeWslDistro(projectRuntimeByRepoId.get(repo.id))
|
||||
return distro ? [[repo.id, distro]] : []
|
||||
})
|
||||
)
|
||||
|
||||
@@ -10,7 +10,10 @@ vi.mock('../ipc/tui-agent-inventory-detection', () => ({
|
||||
detectRemoteAgentCommands: mocks.detectRemote
|
||||
}))
|
||||
|
||||
import { resolveRuntimeAgentCommandOverrides } from './runtime-cursor-command'
|
||||
import {
|
||||
resetRuntimeCursorCommandCacheForTests,
|
||||
resolveRuntimeAgentCommandOverrides
|
||||
} from './runtime-cursor-command'
|
||||
|
||||
const inventory = (command?: string) => ({
|
||||
version: 1 as const,
|
||||
@@ -19,6 +22,7 @@ const inventory = (command?: string) => ({
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuntimeCursorCommandCacheForTests()
|
||||
mocks.detectLocal.mockReset().mockResolvedValue(inventory())
|
||||
mocks.detectRemote.mockReset().mockResolvedValue(inventory())
|
||||
})
|
||||
@@ -85,6 +89,28 @@ describe('resolveRuntimeAgentCommandOverrides', () => {
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('probes once per host and keeps separate hosts isolated', async () => {
|
||||
mocks.detectLocal.mockResolvedValue(inventory('cursor-agent'))
|
||||
mocks.detectRemote.mockResolvedValue(inventory('cursor agent'))
|
||||
const local = { agent: 'cursor' as const, cmdOverrides: {}, workspacePath: '/repo' }
|
||||
await expect(
|
||||
Promise.all([
|
||||
resolveRuntimeAgentCommandOverrides(local),
|
||||
resolveRuntimeAgentCommandOverrides(local),
|
||||
resolveRuntimeAgentCommandOverrides({ ...local, connectionId: 'ssh-1' })
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{ cursor: 'cursor-agent' },
|
||||
{ cursor: 'cursor-agent' },
|
||||
{ cursor: 'cursor agent' }
|
||||
])
|
||||
await expect(resolveRuntimeAgentCommandOverrides(local)).resolves.toEqual({
|
||||
cursor: 'cursor-agent'
|
||||
})
|
||||
expect(mocks.detectLocal).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.detectRemote).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not probe commands for other agents', async () => {
|
||||
const overrides = { claude: 'claude-dev' }
|
||||
await expect(
|
||||
|
||||
@@ -6,6 +6,20 @@ import {
|
||||
detectRemoteAgentCommands
|
||||
} from '../ipc/tui-agent-inventory-detection'
|
||||
|
||||
// Why: every launch/resume/draft path awaits this, and probing spawns shell
|
||||
// detection or an SSH inventory RPC. Keep it short so a fresh install is picked
|
||||
// up quickly while a burst of terminals shares one probe.
|
||||
const CURSOR_COMMAND_CACHE_TTL_MS = 30_000
|
||||
const CURSOR_COMMAND_CACHE_MAX_KEYS = 16
|
||||
|
||||
type CursorCommandCacheEntry = {
|
||||
command?: string | null
|
||||
expiresAt: number
|
||||
inflight?: Promise<string | null>
|
||||
}
|
||||
|
||||
const cursorCommandCache = new Map<string, CursorCommandCacheEntry>()
|
||||
|
||||
export async function resolveRuntimeAgentCommandOverrides(args: {
|
||||
agent: TuiAgent
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
@@ -18,12 +32,66 @@ export async function resolveRuntimeAgentCommandOverrides(args: {
|
||||
}
|
||||
try {
|
||||
const wslDistro = args.wslDistro?.trim() || parseWslUncPath(args.workspacePath)?.distro
|
||||
const inventory = args.connectionId
|
||||
? await detectRemoteAgentCommands({ connectionId: args.connectionId })
|
||||
: await detectInstalledAgentCommandsWithShellPathHydration(wslDistro ? { wslDistro } : {})
|
||||
const command = resolveEffectiveCursorCommand(null, inventory)
|
||||
const command = await cachedCursorCommand(args.connectionId ?? null, wslDistro ?? null)
|
||||
return command ? { ...args.cmdOverrides, cursor: command } : args.cmdOverrides
|
||||
} catch {
|
||||
return args.cmdOverrides
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRuntimeCursorCommandCacheForTests(): void {
|
||||
cursorCommandCache.clear()
|
||||
}
|
||||
|
||||
async function cachedCursorCommand(
|
||||
connectionId: string | null,
|
||||
wslDistro: string | null
|
||||
): Promise<string | null> {
|
||||
const key = JSON.stringify({ connectionId, wslDistro })
|
||||
const existing = cursorCommandCache.get(key)
|
||||
if (existing?.inflight) {
|
||||
return existing.inflight
|
||||
}
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return existing.command ?? null
|
||||
}
|
||||
const entry: CursorCommandCacheEntry = existing ?? { expiresAt: 0 }
|
||||
const inflight = detectCursorCommand(connectionId, wslDistro)
|
||||
.then((command) => {
|
||||
entry.command = command
|
||||
entry.expiresAt = Date.now() + CURSOR_COMMAND_CACHE_TTL_MS
|
||||
return command
|
||||
})
|
||||
.finally(() => {
|
||||
if (entry.inflight === inflight) {
|
||||
entry.inflight = undefined
|
||||
}
|
||||
})
|
||||
entry.inflight = inflight
|
||||
cursorCommandCache.delete(key)
|
||||
cursorCommandCache.set(key, entry)
|
||||
evictCursorCommandCache(key)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function detectCursorCommand(
|
||||
connectionId: string | null,
|
||||
wslDistro: string | null
|
||||
): Promise<string | null> {
|
||||
const inventory = connectionId
|
||||
? await detectRemoteAgentCommands({ connectionId })
|
||||
: await detectInstalledAgentCommandsWithShellPathHydration(wslDistro ? { wslDistro } : {})
|
||||
return resolveEffectiveCursorCommand(null, inventory)
|
||||
}
|
||||
|
||||
function evictCursorCommandCache(activeKey: string): void {
|
||||
while (cursorCommandCache.size > CURSOR_COMMAND_CACHE_MAX_KEYS) {
|
||||
const candidate = [...cursorCommandCache.entries()].find(
|
||||
([key, entry]) => key !== activeKey && !entry.inflight
|
||||
)
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
cursorCommandCache.delete(candidate[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ async function scopeBuckets(
|
||||
const buckets = new Map<string, Bucket>()
|
||||
args.response.scopeCwds = [...cwds].sort()
|
||||
for (const cwd of args.response.scopeCwds) {
|
||||
// Cursor names its bucket dirs md5(cwd); this mirrors that, not a security primitive.
|
||||
const name = createHash('md5').update(cwd).digest('hex')
|
||||
buckets.set(name, { name, path: join(chatsRoot, name), scopeCwd: cwd })
|
||||
}
|
||||
|
||||
@@ -82,28 +82,31 @@ describe('scanCursorSidecars', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps missing files silent and rejects symlinked sidecars', async () => {
|
||||
const root = await createRoot()
|
||||
const chatsRoot = join(root, 'chats')
|
||||
const bucket = '11111111111111111111111111111111'
|
||||
await addSession(chatsRoot, bucket, 'valid')
|
||||
const missingStore = join(chatsRoot, bucket, 'missing-store')
|
||||
await mkdir(missingStore)
|
||||
await writeFile(join(missingStore, 'meta.json'), '{}')
|
||||
const linked = join(chatsRoot, bucket, 'linked')
|
||||
await mkdir(linked)
|
||||
await Promise.all([
|
||||
symlink(join(chatsRoot, bucket, 'valid', 'meta.json'), join(linked, 'meta.json')),
|
||||
writeFile(join(linked, 'store.db'), '')
|
||||
])
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps missing files silent and rejects symlinked sidecars',
|
||||
async () => {
|
||||
const root = await createRoot()
|
||||
const chatsRoot = join(root, 'chats')
|
||||
const bucket = '11111111111111111111111111111111'
|
||||
await addSession(chatsRoot, bucket, 'valid')
|
||||
const missingStore = join(chatsRoot, bucket, 'missing-store')
|
||||
await mkdir(missingStore)
|
||||
await writeFile(join(missingStore, 'meta.json'), '{}')
|
||||
const linked = join(chatsRoot, bucket, 'linked')
|
||||
await mkdir(linked)
|
||||
await Promise.all([
|
||||
symlink(join(chatsRoot, bucket, 'valid', 'meta.json'), join(linked, 'meta.json')),
|
||||
writeFile(join(linked, 'store.db'), '')
|
||||
])
|
||||
|
||||
const result = await scanCursorSidecars(
|
||||
defaultCursorSidecarScanRequest(chatsRoot, [], process.platform),
|
||||
context
|
||||
)
|
||||
expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['valid'])
|
||||
expect(result.issues).toEqual([])
|
||||
})
|
||||
const result = await scanCursorSidecars(
|
||||
defaultCursorSidecarScanRequest(chatsRoot, [], process.platform),
|
||||
context
|
||||
)
|
||||
expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['valid'])
|
||||
expect(result.issues).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('clamps session and aggregate-content bounds and reports one issue per dimension', async () => {
|
||||
const root = await createRoot()
|
||||
|
||||
@@ -32,7 +32,7 @@ type VerifiedTerminalArtifactOptions = {
|
||||
export async function readVerifiedTerminalArtifact(params: Record<string, unknown>) {
|
||||
const filePath = stringParam(params.filePath)
|
||||
const options = verifiedTerminalArtifactOptions(params)
|
||||
const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY)
|
||||
const handle = await openVerifiedTerminalArtifact(filePath, options)
|
||||
try {
|
||||
await verifiedHandleStat(handle, options)
|
||||
const mimeType = terminalArtifactImageMimeType(filePath)
|
||||
@@ -64,7 +64,7 @@ export async function writeVerifiedTerminalArtifact(
|
||||
if (Buffer.byteLength(content, 'utf8') > writeLimit) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY)
|
||||
const handle = await openVerifiedTerminalArtifact(filePath, options)
|
||||
let originalMode: number | null = null
|
||||
try {
|
||||
originalMode = (await verifiedHandleStat(handle, options)).mode ?? null
|
||||
@@ -81,7 +81,7 @@ export async function writeVerifiedTerminalArtifact(
|
||||
if (typeof originalMode === 'number') {
|
||||
await chmod(tempPath, originalMode & 0o7777)
|
||||
}
|
||||
const freshHandle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY)
|
||||
const freshHandle = await openVerifiedTerminalArtifact(filePath, options)
|
||||
try {
|
||||
await verifiedHandleStat(freshHandle, options)
|
||||
} finally {
|
||||
@@ -110,17 +110,15 @@ function terminalArtifactImageMimeType(filePath: string): string | undefined {
|
||||
return mimeType === 'image/svg+xml' ? undefined : mimeType
|
||||
}
|
||||
|
||||
// Why: read-only + O_NOFOLLOW is the whole guard here; a caller-supplied flag
|
||||
// set would make it trivial to reintroduce symlink-follow on a temp path.
|
||||
async function openVerifiedTerminalArtifact(
|
||||
filePath: string,
|
||||
options: VerifiedTerminalArtifactOptions,
|
||||
flags: number
|
||||
options: VerifiedTerminalArtifactOptions
|
||||
): Promise<FileHandle> {
|
||||
await assertRealPathStillGranted(filePath, options.expectedRealPath)
|
||||
try {
|
||||
if (flags === constants.O_RDONLY) {
|
||||
return await openNoFollow(filePath)
|
||||
}
|
||||
return await open(filePath, flags)
|
||||
return await openNoFollow(filePath)
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === 'ELOOP' ||
|
||||
|
||||
@@ -117,7 +117,6 @@ export class PreflightHandler {
|
||||
(command) =>
|
||||
!isDetectionUnsupportedInRuntime(command, process.platform) &&
|
||||
foundCommands.has(command.cmd) &&
|
||||
(!command.capabilityProbe || foundCommands.has(command.cmd)) &&
|
||||
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
|
||||
)
|
||||
.map(({ id }) => id)
|
||||
|
||||
@@ -2,15 +2,24 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path'
|
||||
|
||||
// Cursor resumes by re-running its CLI in the recorded cwd, so both gating
|
||||
// paths need the same triple; keep them from drifting apart.
|
||||
export function hasCursorResumeTarget(
|
||||
session: Partial<Pick<AiVaultSession, 'agent' | 'cwd' | 'resumeCommand'>>,
|
||||
cursorCommandAvailable: boolean
|
||||
): boolean {
|
||||
return (
|
||||
session.agent !== 'cursor' ||
|
||||
Boolean(session.cwd && session.resumeCommand?.trim() && cursorCommandAvailable)
|
||||
)
|
||||
}
|
||||
|
||||
export function canContinueAiVaultSessionInNewSession(
|
||||
session: AiVaultSession,
|
||||
targetWorktreeId: string | null | undefined,
|
||||
cursorCommandAvailable = false
|
||||
): boolean {
|
||||
if (
|
||||
session.agent === 'cursor' &&
|
||||
(!session.cwd || !session.resumeCommand.trim() || !cursorCommandAvailable)
|
||||
) {
|
||||
if (!hasCursorResumeTarget(session, cursorCommandAvailable)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
canJumpToAiVaultSessionWorktree,
|
||||
type AiVaultSessionWorktreeInfo
|
||||
} from './ai-vault-session-worktree'
|
||||
import { hasCursorResumeTarget } from './ai-vault-session-continuation'
|
||||
|
||||
export type AiVaultSessionResumeTargetState = Pick<
|
||||
AppState,
|
||||
@@ -228,12 +229,10 @@ export function aiVaultSessionRowResumeGating(
|
||||
| null
|
||||
): { resumeDisabled: boolean; canCopyResumeCommand: boolean } {
|
||||
const hasResumableContent = isAiVaultSessionResumableContent(session)
|
||||
const hasCursorResumeTarget =
|
||||
session.agent !== 'cursor' ||
|
||||
Boolean(session.cwd && session.resumeCommand?.trim() && state?.cursorCommandAvailable === true)
|
||||
const cursorResumable = hasCursorResumeTarget(session, state?.cursorCommandAvailable === true)
|
||||
return {
|
||||
resumeDisabled: (state?.blocked ?? true) || !hasResumableContent || !hasCursorResumeTarget,
|
||||
canCopyResumeCommand: hasResumableContent && hasCursorResumeTarget
|
||||
resumeDisabled: (state?.blocked ?? true) || !hasResumableContent || !cursorResumable,
|
||||
canCopyResumeCommand: hasResumableContent && cursorResumable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ describe('resolveAiVaultCursorCommand', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the flat command map only when no context-indexed map exists', () => {
|
||||
expect(
|
||||
resolveAiVaultCursorCommand({
|
||||
state: state({ detectedAgentCommands: { cursor: 'cursor-agent' } })
|
||||
})
|
||||
).toBe('cursor-agent')
|
||||
expect(
|
||||
resolveAiVaultCursorCommand({
|
||||
state: state({
|
||||
detectedAgentCommands: { cursor: 'cursor-agent' },
|
||||
detectedAgentCommandsByContext: { 'wsl:Ubuntu': { cursor: 'cursor agent' } }
|
||||
})
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers an explicit override over every detected match', () => {
|
||||
expect(
|
||||
resolveAiVaultCursorCommand({
|
||||
|
||||
@@ -65,7 +65,11 @@ export function resolveAiVaultCursorCommand(args: {
|
||||
args.worktreeId
|
||||
)
|
||||
)
|
||||
matches = args.state.detectedAgentCommandsByContext?.[contextKey]
|
||||
// Older preflight/preload payloads carry only the flat map; keep a missing
|
||||
// context result authoritative once the context-indexed map exists.
|
||||
matches = args.state.detectedAgentCommandsByContext
|
||||
? args.state.detectedAgentCommandsByContext[contextKey]
|
||||
: args.state.detectedAgentCommands
|
||||
}
|
||||
return resolveEffectiveCursorCommand(args.commandOverride, {
|
||||
version: 1,
|
||||
|
||||
@@ -282,9 +282,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
||||
...settings,
|
||||
agentCmdOverrides: effectiveAgent
|
||||
? resolveCursorCommandOverrides({
|
||||
state: store,
|
||||
// Why: the pre-create snapshot lacks the new worktree, so
|
||||
// Cursor would resolve against the active/local host.
|
||||
state: latestStore,
|
||||
agent: effectiveAgent,
|
||||
worktreeId,
|
||||
repoId: repo.id,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {}
|
||||
})
|
||||
: settings.agentCmdOverrides
|
||||
|
||||
@@ -22,11 +22,11 @@ export const cursorSidecarScanRequestSchema = z
|
||||
version: z.literal(CURSOR_SIDECAR_SCAN_VERSION),
|
||||
chatsRoot: boundedPath,
|
||||
scopePaths: z.array(boundedPath).max(CURSOR_REMOTE_MAX_SCOPE_PATHS),
|
||||
maxBuckets: positiveSafeInteger,
|
||||
maxSessionDirs: positiveSafeInteger,
|
||||
maxScopePaths: positiveSafeInteger,
|
||||
maxSidecarBytes: positiveSafeInteger,
|
||||
maxAggregateBytes: positiveSafeInteger
|
||||
maxBuckets: positiveSafeInteger.max(CURSOR_REMOTE_MAX_BUCKETS),
|
||||
maxSessionDirs: positiveSafeInteger.max(CURSOR_REMOTE_MAX_SESSION_DIRS),
|
||||
maxScopePaths: positiveSafeInteger.max(CURSOR_REMOTE_MAX_SCOPE_PATHS),
|
||||
maxSidecarBytes: positiveSafeInteger.max(CURSOR_SIDECAR_MAX_BYTES),
|
||||
maxAggregateBytes: positiveSafeInteger.max(CURSOR_REMOTE_MAX_AGGREGATE_BYTES)
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user