mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge branch 'main' into fix/7716-relay-cli-orchestration
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.9m">
|
||||
<title>downloads: 3.9m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.0m">
|
||||
<title>downloads: 4.0m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.9m</text>
|
||||
<text x="93.5" y="14">3.9m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.0m</text>
|
||||
<text x="93.5" y="14">4.0m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.129-rc.0",
|
||||
"version": "1.4.129-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
listEnvironments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
listEnvironments: mocks.listEnvironments
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
|
||||
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
|
||||
await import('./runtime-session-scanner')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listEnvironments.mockReturnValue([])
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: result([session('runtime:env-1', 'session-1')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime AI Vault session scanner', () => {
|
||||
it('lists saved runtime environments as runtime execution hosts', () => {
|
||||
mocks.listEnvironments.mockReturnValue([
|
||||
{ id: 'env-1', name: 'Server 1' },
|
||||
{ id: 'env-2', name: 'Server 2' }
|
||||
])
|
||||
|
||||
expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([
|
||||
{ environmentId: 'env-1', executionHostId: 'runtime:env-1' },
|
||||
{ environmentId: 'env-2', executionHostId: 'runtime:env-2' }
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards scan options to the runtime transport', async () => {
|
||||
await scanRuntimeAiVaultSessions(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
{
|
||||
limit: 25,
|
||||
force: true,
|
||||
scopePaths: ['/srv/app']
|
||||
},
|
||||
{ timeoutMs: 3000 }
|
||||
)
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
limit: 25,
|
||||
force: true,
|
||||
scopePaths: ['/srv/app'],
|
||||
executionHostId: 'runtime:env-1'
|
||||
},
|
||||
3000
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps sessions and issues returned for a different execution host', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: result(
|
||||
[session('local', 'session-1')],
|
||||
[
|
||||
{
|
||||
executionHostId: 'ssh:dev-box',
|
||||
agent: 'codex',
|
||||
path: '/sessions/session-1.jsonl',
|
||||
message: 'could not parse session'
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
|
||||
|
||||
expect(scanResult.sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
expect(scanResult.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'runtime:env-1',
|
||||
agent: 'codex',
|
||||
path: '/sessions/session-1.jsonl'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('stamps accepted sessions with the requested runtime host', async () => {
|
||||
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
|
||||
|
||||
expect(scanResult.sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function result(
|
||||
sessions: AiVaultSession[],
|
||||
issues: AiVaultListResult['issues'] = []
|
||||
): AiVaultListResult {
|
||||
return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/srv/app',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: '2026-07-04T03:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AI_VAULT_AGENTS,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult,
|
||||
type AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
executionHostId: `runtime:${string}`
|
||||
}
|
||||
|
||||
export type RuntimeAiVaultScanOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const nodePlatformSchema = z.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
] satisfies NodeJS.Platform[])
|
||||
|
||||
const aiVaultSessionPreviewMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
|
||||
text: z.string(),
|
||||
timestamp: z.string().nullable()
|
||||
})
|
||||
|
||||
const executionHostIdSchema = z.string().transform((value, ctx) => {
|
||||
const normalized = normalizeExecutionHostId(value)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid execution host id'
|
||||
})
|
||||
return z.NEVER
|
||||
})
|
||||
|
||||
const aiVaultListResultSchema = z.object({
|
||||
sessions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
executionHostId: executionHostIdSchema,
|
||||
executionHostPlatform: nodePlatformSchema.nullable().optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionId: z.string(),
|
||||
title: z.string(),
|
||||
cwd: z.string().nullable(),
|
||||
branch: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
filePath: z.string(),
|
||||
codexHome: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
modifiedAt: z.string(),
|
||||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
|
||||
resumeCommand: z.string()
|
||||
})
|
||||
),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
executionHostId: executionHostIdSchema.optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
path: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
),
|
||||
scannedAt: z.string()
|
||||
})
|
||||
|
||||
export function getSavedRuntimeAiVaultHostInfos(
|
||||
userDataPath: string
|
||||
): readonly RuntimeAiVaultHostInfo[] {
|
||||
return listEnvironments(userDataPath).map((environment) => ({
|
||||
environmentId: environment.id,
|
||||
executionHostId: toRuntimeExecutionHostId(environment.id)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function scanRuntimeAiVaultSessions(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
args: AiVaultListArgs,
|
||||
options: RuntimeAiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toRuntimeExecutionHostId(environmentId)
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
limit: args.limit,
|
||||
force: args.force,
|
||||
scopePaths: args.scopePaths,
|
||||
executionHostId
|
||||
},
|
||||
options.timeoutMs
|
||||
)
|
||||
if (response.ok === true) {
|
||||
const parsed = aiVaultListResultSchema.safeParse(response.result)
|
||||
if (parsed.success) {
|
||||
return withRuntimeExecutionHost(parsed.data, executionHostId)
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: `Invalid aiVault.listSessions response: ${
|
||||
parsed.error.issues[0]?.message ?? 'unexpected result shape'
|
||||
}`
|
||||
})
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: response.error.message
|
||||
})
|
||||
}
|
||||
|
||||
function withRuntimeExecutionHost(
|
||||
result: AiVaultListResult,
|
||||
executionHostId: `runtime:${string}`
|
||||
): AiVaultListResult {
|
||||
return {
|
||||
sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)),
|
||||
issues: result.issues.map((issue) => ({ ...issue, executionHostId })),
|
||||
scannedAt: result.scannedAt
|
||||
}
|
||||
}
|
||||
|
||||
function retagRuntimeSession(
|
||||
session: AiVaultSession,
|
||||
executionHostId: `runtime:${string}`
|
||||
): AiVaultSession {
|
||||
// The paired server is the transport, but the parent owns which concrete
|
||||
// runtime host was scanned; never trust returned host ids across the boundary.
|
||||
return {
|
||||
...session,
|
||||
executionHostId,
|
||||
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeScanIssueResult(args: {
|
||||
executionHostId: `runtime:${string}`
|
||||
environmentId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.environmentId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
|
||||
export function aiVaultScanIssueResult(args: {
|
||||
executionHostId?: ExecutionHostId
|
||||
path: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
|
||||
agent: 'codex',
|
||||
path: args.path,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { createSystemTray, destroySystemTray } from './tray/system-tray'
|
||||
import { focusExistingMainWindow } from './window/focus-existing-window'
|
||||
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
|
||||
import { CodexAccountService } from './codex-accounts/service'
|
||||
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
||||
import {
|
||||
@@ -986,6 +987,11 @@ function openMainWindow(): BrowserWindow {
|
||||
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
||||
window.on('minimize', stopSyntheticTitleSpinnerTimer)
|
||||
// Why: visibility-gated main-process pollers (SSH port scanner) park while
|
||||
// hidden and rely on this signal to resume; re-wired per window because
|
||||
// macOS dock re-activation recreates the BrowserWindow.
|
||||
window.on('show', notifyMainWindowBecameVisible)
|
||||
window.on('restore', notifyMainWindowBecameVisible)
|
||||
agentHookServer.setListener(
|
||||
({
|
||||
paneKey,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
scanRuntimeAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
@@ -41,7 +42,7 @@ vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
|
||||
}))
|
||||
|
||||
const { _internals } = await import('./ai-vault')
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
@@ -52,6 +53,9 @@ beforeEach(() => {
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(
|
||||
result([session('runtime:remote-server', 'runtime-session')])
|
||||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
@@ -96,6 +100,77 @@ describe('listAiVaultSessions host routing', () => {
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
})
|
||||
|
||||
it('merges paired runtime servers for all hosts', async () => {
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [
|
||||
{
|
||||
environmentId: 'remote-server',
|
||||
executionHostId: 'runtime:remote-server'
|
||||
}
|
||||
],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith(
|
||||
'remote-server',
|
||||
{
|
||||
executionHostScope: 'runtime:remote-server'
|
||||
},
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) })
|
||||
)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([
|
||||
'runtime:remote-server',
|
||||
'ssh:dev-box',
|
||||
'local'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local and SSH results when runtime host discovery fails', async () => {
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => {
|
||||
throw new Error('runtime store is invalid')
|
||||
},
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
agent: 'codex',
|
||||
path: 'runtime environments',
|
||||
message: 'runtime store is invalid'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps direct runtime host scans on the normal runtime timeout', async () => {
|
||||
registerAiVaultHandlers({
|
||||
getActiveRuntimeAiVaultHostInfos: () => [],
|
||||
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
|
||||
})
|
||||
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'runtime:remote-server',
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith(
|
||||
'remote-server',
|
||||
{
|
||||
executionHostScope: 'runtime:remote-server',
|
||||
force: true
|
||||
},
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a scan issue for a disconnected SSH target', async () => {
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
|
||||
@@ -153,7 +228,11 @@ function session(
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt:
|
||||
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
|
||||
sessionId === 'runtime-session'
|
||||
? '2026-07-04T03:00:00.000Z'
|
||||
: sessionId === 'remote-session'
|
||||
? '2026-07-04T02:00:00.000Z'
|
||||
: '2026-07-04T01:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
|
||||
+113
-52
@@ -2,17 +2,14 @@ import { app, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
|
||||
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
|
||||
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../shared/execution-host'
|
||||
@@ -23,9 +20,20 @@ import {
|
||||
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
|
||||
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
|
||||
|
||||
type AiVaultHandlerOptions = {
|
||||
getAdditionalCodexHomePaths?: () => readonly string[]
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: (
|
||||
environmentId: string,
|
||||
args: AiVaultListArgs,
|
||||
options?: RuntimeAiVaultScanOptions
|
||||
) => Promise<AiVaultListResult>
|
||||
}
|
||||
|
||||
type RuntimeAiVaultScanOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
type CachedAiVaultList = {
|
||||
@@ -34,6 +42,11 @@ type CachedAiVaultList = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
executionHostId: `runtime:${string}`
|
||||
}
|
||||
|
||||
let cachedList: CachedAiVaultList | null = null
|
||||
let inflightList: Promise<AiVaultListResult> | null = null
|
||||
let inflightKey: string | null = null
|
||||
@@ -89,12 +102,20 @@ async function scanAiVaultSessionsByHostScope(
|
||||
}
|
||||
|
||||
if (executionHostScope === 'all') {
|
||||
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
|
||||
const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : []
|
||||
return mergeAiVaultListResults(
|
||||
await Promise.all([
|
||||
scanLocalAiVaultSessions(args),
|
||||
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args)
|
||||
)
|
||||
),
|
||||
...runtimeHosts.hostInfos.map((hostInfo) =>
|
||||
scanRuntimeAiVaultSessions(hostInfo, args, {
|
||||
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
|
||||
})
|
||||
),
|
||||
...runtimeResults
|
||||
]),
|
||||
args?.limit
|
||||
)
|
||||
@@ -104,19 +125,88 @@ async function scanAiVaultSessionsByHostScope(
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return scanSshAiVaultSessions(parsed.targetId, args)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
if (parsed?.kind === 'runtime') {
|
||||
return scanRuntimeAiVaultSessions(
|
||||
{
|
||||
executionHostId: executionHostScope,
|
||||
agent: 'codex',
|
||||
path: executionHostScope,
|
||||
message: 'Agent Session History is not available for this execution host.'
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
environmentId: parsed.environmentId,
|
||||
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
|
||||
},
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: executionHostScope,
|
||||
path: executionHostScope,
|
||||
message: 'Agent Session History is not available for this execution host.'
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] {
|
||||
return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? []
|
||||
}
|
||||
|
||||
function getActiveRuntimeAiVaultHostInfosResult(): {
|
||||
hostInfos: readonly RuntimeAiVaultHostInfo[]
|
||||
issue?: AiVaultListResult
|
||||
} {
|
||||
try {
|
||||
return { hostInfos: getActiveRuntimeAiVaultHostInfos() }
|
||||
} catch (error) {
|
||||
return {
|
||||
hostInfos: [],
|
||||
issue: runtimeHostDiscoveryIssueResult(
|
||||
error instanceof Error ? error.message : 'Runtime hosts are unavailable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRuntimeAiVaultSessions(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
args?: AiVaultListArgs,
|
||||
options: RuntimeAiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const scanner = handlerOptions.scanRuntimeAiVaultSessions
|
||||
if (!scanner) {
|
||||
return runtimeScanIssueResult(
|
||||
hostInfo,
|
||||
'Agent Session History is not available for this execution host.'
|
||||
)
|
||||
}
|
||||
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
|
||||
if (args?.limit !== undefined) {
|
||||
scanArgs.limit = args.limit
|
||||
}
|
||||
if (args?.force !== undefined) {
|
||||
scanArgs.force = args.force
|
||||
}
|
||||
if (args?.scopePaths !== undefined) {
|
||||
scanArgs.scopePaths = args.scopePaths
|
||||
}
|
||||
try {
|
||||
return await scanner(hostInfo.environmentId, scanArgs, options)
|
||||
} catch (error) {
|
||||
return runtimeScanIssueResult(
|
||||
hostInfo,
|
||||
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeScanIssueResult(
|
||||
hostInfo: RuntimeAiVaultHostInfo,
|
||||
message: string
|
||||
): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
path: hostInfo.environmentId,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult {
|
||||
return aiVaultScanIssueResult({ path: 'runtime environments', message })
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
@@ -161,40 +251,11 @@ function sshScanIssueResult(args: {
|
||||
targetId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
return aiVaultScanIssueResult({
|
||||
executionHostId: args.executionHostId,
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
})
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
|
||||
+117
-36
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -5,7 +6,7 @@ const {
|
||||
appExitMock,
|
||||
appQuitMock,
|
||||
appRelaunchMock,
|
||||
execFileMock,
|
||||
spawnMock,
|
||||
destroySystemTrayMock,
|
||||
showOpenDialogMock,
|
||||
grantFloatingWorkspaceDirectoryMock
|
||||
@@ -14,16 +15,53 @@ const {
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
destroySystemTrayMock: vi.fn(),
|
||||
showOpenDialogMock: vi.fn(),
|
||||
grantFloatingWorkspaceDirectoryMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
// Fakes the detached `spawn` child: a stdout EventEmitter plus close/error
|
||||
// events, so tests drive the async command lifecycle readCommandStdout expects.
|
||||
function createFakeSpawnChild(options: {
|
||||
stdout?: string
|
||||
code?: number
|
||||
error?: Error
|
||||
pid?: number
|
||||
hang?: boolean
|
||||
}): EventEmitter & { pid: number; kill: ReturnType<typeof vi.fn>; stdout: EventEmitter } {
|
||||
const { stdout, code = 0, error, pid = 4242, hang = false } = options
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid: number
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
stdout: EventEmitter & { setEncoding: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
child.pid = pid
|
||||
child.kill = vi.fn()
|
||||
const stdoutStream = new EventEmitter() as EventEmitter & {
|
||||
setEncoding: ReturnType<typeof vi.fn>
|
||||
}
|
||||
stdoutStream.setEncoding = vi.fn()
|
||||
child.stdout = stdoutStream
|
||||
if (!hang) {
|
||||
queueMicrotask(() => {
|
||||
if (error) {
|
||||
child.emit('error', error)
|
||||
return
|
||||
}
|
||||
if (stdout !== undefined) {
|
||||
stdoutStream.emit('data', stdout)
|
||||
}
|
||||
child.emit('close', code)
|
||||
})
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
exit: appExitMock,
|
||||
@@ -63,6 +101,9 @@ import { registerAppHandlers } from './app'
|
||||
|
||||
describe('registerAppHandlers', () => {
|
||||
const originalPlatform = process.platform
|
||||
// Why: readCommandStdout process-group-kills on timeout; stub the real signal
|
||||
// so a fake child pid can never target a live process group during tests.
|
||||
let processKillSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -70,14 +111,16 @@ describe('registerAppHandlers', () => {
|
||||
appExitMock.mockReset()
|
||||
appQuitMock.mockReset()
|
||||
appRelaunchMock.mockReset()
|
||||
execFileMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
destroySystemTrayMock.mockReset()
|
||||
showOpenDialogMock.mockReset()
|
||||
grantFloatingWorkspaceDirectoryMock.mockReset()
|
||||
processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true)
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
processKillSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
|
||||
})
|
||||
@@ -177,10 +220,9 @@ describe('registerAppHandlers', () => {
|
||||
|
||||
it('returns the selected macOS input mode before the keyboard layout fallback', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify([
|
||||
spawnMock.mockImplementation(() =>
|
||||
createFakeSpawnChild({
|
||||
stdout: JSON.stringify([
|
||||
{ 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' },
|
||||
{
|
||||
'Bundle ID': 'com.apple.inputmethod.SCIM',
|
||||
@@ -188,60 +230,96 @@ describe('registerAppHandlers', () => {
|
||||
InputSourceKind: 'Input Mode'
|
||||
}
|
||||
])
|
||||
)
|
||||
return { kill: vi.fn() }
|
||||
})
|
||||
})
|
||||
)
|
||||
registerAppHandlers({} as never)
|
||||
|
||||
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
||||
'com.apple.inputmethod.SCIM.ITABC'
|
||||
)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'/usr/bin/plutil',
|
||||
expect.arrayContaining(['AppleSelectedInputSources']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
// Why: macOS 15's `plutil -extract <key> json` aborts on the input-source
|
||||
// array, so the probe reads live cfprefsd via `defaults export` and dodges
|
||||
// the bug with an xml1 extract before converting the clean subtree to JSON.
|
||||
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
|
||||
// stage silently regressing CJK detection to the fallback fails the test.
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
|
||||
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
|
||||
'/usr/bin/plutil -convert json -o - -'
|
||||
],
|
||||
expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the keyboard layout when no keyboard input mode is selected', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||
execFileMock
|
||||
.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify([
|
||||
spawnMock
|
||||
.mockImplementationOnce(() =>
|
||||
createFakeSpawnChild({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
'Bundle ID': 'com.apple.PressAndHold',
|
||||
InputSourceKind: 'Non Keyboard Input Method'
|
||||
}
|
||||
])
|
||||
)
|
||||
return { kill: vi.fn() }
|
||||
})
|
||||
.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, 'com.apple.keylayout.ABC\n')
|
||||
return { kill: vi.fn() }
|
||||
})
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
|
||||
registerAppHandlers({} as never)
|
||||
|
||||
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
||||
'com.apple.keylayout.ABC'
|
||||
)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
expect(execFileMock).toHaveBeenLastCalledWith(
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
||||
'/usr/bin/defaults',
|
||||
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect.objectContaining({ detached: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||
// Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so
|
||||
// the probe rejects on the `close` branch and the handler falls back.
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => createFakeSpawnChild({ code: 1 }))
|
||||
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
|
||||
registerAppHandlers({} as never)
|
||||
|
||||
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
||||
'com.apple.keylayout.ABC'
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
||||
'/usr/bin/defaults',
|
||||
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
|
||||
expect.objectContaining({ detached: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||
// Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must
|
||||
// still fall back rather than reject out of the IPC call.
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') }))
|
||||
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
|
||||
registerAppHandlers({} as never)
|
||||
|
||||
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
||||
'com.apple.keylayout.ABC'
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back when macOS keyboard input source probes never report completion', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||
const killMock = vi.fn()
|
||||
execFileMock.mockImplementation(() => ({ kill: killMock }))
|
||||
spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true }))
|
||||
registerAppHandlers({} as never)
|
||||
|
||||
const handler = handlers.get('app:getKeyboardInputSourceId')
|
||||
@@ -256,7 +334,10 @@ describe('registerAppHandlers', () => {
|
||||
|
||||
expect(settled).toBe(true)
|
||||
await expect(resultPromise).resolves.toBeNull()
|
||||
expect(killMock).toHaveBeenCalledTimes(2)
|
||||
// Why: both wedged probes get a process-group SIGKILL (negative pid) so the
|
||||
// shell and any orphaned `defaults`/`plutil` stages are reaped on timeout.
|
||||
expect(processKillSpy).toHaveBeenCalledTimes(2)
|
||||
expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('picks an existing floating workspace directory without enabling native directory creation', async () => {
|
||||
|
||||
+58
-29
@@ -1,6 +1,5 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron'
|
||||
@@ -24,12 +23,15 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown
|
||||
|
||||
const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500
|
||||
const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox'
|
||||
const MAC_HITOOLBOX_PREFERENCES_PATH = path.join(
|
||||
homedir(),
|
||||
'Library',
|
||||
'Preferences',
|
||||
`${MAC_HITOOLBOX_DOMAIN}.plist`
|
||||
)
|
||||
// Why: macOS 15's `plutil -extract <key> json` aborts on the (pure-string)
|
||||
// input-source array and the on-disk plist lags cfprefsd; read live prefs via
|
||||
// `defaults export`, extract as xml1 (dodges the json bug), then convert to JSON.
|
||||
// Absolute paths so a GUI-launched app's minimal PATH can't shadow the tools.
|
||||
const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [
|
||||
`/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`,
|
||||
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -',
|
||||
'/usr/bin/plutil -convert json -o - -'
|
||||
].join(' | ')
|
||||
|
||||
type RegisterAppHandlersOptions = {
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
@@ -118,13 +120,32 @@ function readCommandStdout(
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let child: ReturnType<typeof execFile> | undefined
|
||||
let child: ReturnType<typeof spawn> | undefined
|
||||
|
||||
// Why: the probe runs a `/bin/sh -c` pipeline, so signaling only the shell
|
||||
// orphans wedged `defaults`/`plutil` stages on a stuck cfprefsd. Spawning
|
||||
// detached makes the child a process-group leader, so one negative-pid
|
||||
// SIGKILL reaps the shell and every stage; child.kill() covers the fallback.
|
||||
const killTree = (): void => {
|
||||
if (!child?.pid) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
} catch {
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: short timeout so a wedged macOS preference probe (corporate-managed
|
||||
// config, sandbox policy, ...) never holds the handle indefinitely. This
|
||||
// manual timer is the only timeout guard, so it owns the process-group kill.
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
child?.kill()
|
||||
killTree()
|
||||
reject(new Error(timeoutMessage))
|
||||
}, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS)
|
||||
|
||||
@@ -137,24 +158,32 @@ function readCommandStdout(
|
||||
callback()
|
||||
}
|
||||
|
||||
// Why: execFile's timeout only signals `defaults`; if the callback
|
||||
// never arrives, window-focus keyboard probes would remain pending.
|
||||
try {
|
||||
child = execFile(
|
||||
command,
|
||||
args,
|
||||
// Why: short timeout so a wedged macOS preference probe (corporate-managed
|
||||
// config, sandbox policy, ...) never holds the handle indefinitely.
|
||||
// Fall through to the fingerprint on timeout.
|
||||
{ encoding: 'utf8', timeout: KEYBOARD_INPUT_SOURCE_TIMEOUT_MS },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
settle(() => reject(error))
|
||||
return
|
||||
}
|
||||
settle(() => resolve(String(stdout ?? '')))
|
||||
}
|
||||
)
|
||||
child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
let stdout = ''
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
})
|
||||
const failWith = (error: Error): void => {
|
||||
killTree()
|
||||
settle(() => reject(error))
|
||||
}
|
||||
// Why: an unhandled Readable 'error' would crash the main process; treat a
|
||||
// stdout read failure the same as a child spawn error.
|
||||
child.stdout?.on('error', failWith)
|
||||
child.on('error', failWith)
|
||||
child.on('close', (code, signal) => {
|
||||
settle(() =>
|
||||
code === 0
|
||||
? resolve(stdout)
|
||||
: reject(
|
||||
new Error(
|
||||
`${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
settle(() => reject(error))
|
||||
}
|
||||
@@ -196,8 +225,8 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null {
|
||||
async function readSelectedKeyboardInputSourceId(): Promise<string | null> {
|
||||
try {
|
||||
const stdout = await readCommandStdout(
|
||||
'/usr/bin/plutil',
|
||||
['-extract', 'AppleSelectedInputSources', 'json', '-o', '-', MAC_HITOOLBOX_PREFERENCES_PATH],
|
||||
'/bin/sh',
|
||||
['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND],
|
||||
'Selected keyboard input source probe timed out'
|
||||
)
|
||||
return readSelectedInputSourceIdFromJson(stdout)
|
||||
|
||||
@@ -2316,4 +2316,39 @@ describe('registerFilesystemHandlers', () => {
|
||||
excludePaths: ['/home/user/repo/worktrees/feature']
|
||||
})
|
||||
})
|
||||
|
||||
// Why #7721: without a cancel path, every workspace switch left the previous
|
||||
// workspace's full-tree SSH scan running, stacking scans on the relay until
|
||||
// interactive fs.readDir/fs.stat starved past their 30s timeout.
|
||||
it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => {
|
||||
let capturedSignal: AbortSignal | undefined
|
||||
const listFilesMock = vi.fn(
|
||||
(_rootPath: string, options: { signal?: AbortSignal }) =>
|
||||
new Promise<string[]>((_resolve, reject) => {
|
||||
capturedSignal = options.signal
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
const pending = handlers.get('fs:listFiles')!(null, {
|
||||
rootPath: '/home/user/repo',
|
||||
connectionId: 'conn-1',
|
||||
requestToken: 'token-1'
|
||||
}) as Promise<string[]>
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(false)
|
||||
await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' })
|
||||
expect(capturedSignal?.aborted).toBe(true)
|
||||
await expect(pending).rejects.toThrow('listing cancelled')
|
||||
|
||||
// Unknown or already-settled tokens are a no-op, not an error.
|
||||
expect(() =>
|
||||
handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
+42
-14
@@ -1026,30 +1026,58 @@ export function registerFilesystemHandlers(
|
||||
)
|
||||
|
||||
// ─── List all files (for quick-open) ─────────────────────
|
||||
// Why #7721: keyed by renderer-generated token so a workspace switch can
|
||||
// abort the previous workspace's full-tree scan (SSH relays otherwise stack
|
||||
// scans that starve interactive fs.readDir/fs.stat past their 30s timeout).
|
||||
const listFilesCancellations = new Map<string, AbortController>()
|
||||
ipcMain.handle(
|
||||
'fs:listFiles',
|
||||
async (
|
||||
_event,
|
||||
args: { rootPath: string; connectionId?: string; excludePaths?: string[] }
|
||||
args: {
|
||||
rootPath: string
|
||||
connectionId?: string
|
||||
excludePaths?: string[]
|
||||
requestToken?: string
|
||||
}
|
||||
): Promise<string[]> => {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshFilesystemProvider(args.connectionId)
|
||||
// Why: when the SSH connection is not yet established (cold start) or
|
||||
// temporarily disconnected, return [] so quick-open shows "No matching
|
||||
// files" instead of an error banner. The file list will repopulate when
|
||||
// the user re-opens quick-open after the connection is restored.
|
||||
if (!provider) {
|
||||
return []
|
||||
const controller = args.requestToken ? new AbortController() : null
|
||||
if (controller && args.requestToken) {
|
||||
listFilesCancellations.set(args.requestToken, controller)
|
||||
}
|
||||
try {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshFilesystemProvider(args.connectionId)
|
||||
// Why: when the SSH connection is not yet established (cold start) or
|
||||
// temporarily disconnected, return [] so quick-open shows "No matching
|
||||
// files" instead of an error banner. The file list will repopulate when
|
||||
// the user re-opens quick-open after the connection is restored.
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
// Why: forward excludePaths through to the remote provider.
|
||||
// Dropping it here would silently double-scan nested linked worktrees
|
||||
// over SSH and contribute to timeout-induced partial results.
|
||||
return await provider.listFiles(args.rootPath, {
|
||||
excludePaths: args.excludePaths,
|
||||
signal: controller?.signal
|
||||
})
|
||||
}
|
||||
// Why: forward excludePaths through to the remote provider.
|
||||
// Dropping it here would silently double-scan nested linked worktrees
|
||||
// over SSH and contribute to timeout-induced partial results.
|
||||
return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths })
|
||||
return await listQuickOpenFiles(args.rootPath, store, args.excludePaths)
|
||||
} finally {
|
||||
if (args.requestToken) {
|
||||
listFilesCancellations.delete(args.requestToken)
|
||||
}
|
||||
}
|
||||
return listQuickOpenFiles(args.rootPath, store, args.excludePaths)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => {
|
||||
// Why: best-effort — the entry is gone once the listing settles, and
|
||||
// local scans are fast enough to simply let them finish.
|
||||
listFilesCancellations.get(args.requestToken)?.abort()
|
||||
})
|
||||
|
||||
// ─── Git operations ─────────────────────────────────────
|
||||
ipcMain.handle(
|
||||
'git:status',
|
||||
|
||||
+52
-12
@@ -18,11 +18,13 @@ const expectedOmpStatusExtension = posix.join(
|
||||
'extensions',
|
||||
'orca-agent-status.ts'
|
||||
)
|
||||
const expectedAttributionShimDir = join(
|
||||
'/tmp/orca-user-data',
|
||||
'orca-terminal-attribution',
|
||||
isWindowsHost ? 'win32' : 'posix'
|
||||
)
|
||||
function expectedAttributionShimDir(): string {
|
||||
return join(
|
||||
'/tmp/orca-user-data',
|
||||
'orca-terminal-attribution',
|
||||
process.platform === 'win32' ? 'win32' : 'posix'
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -1152,7 +1154,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir)
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('skips git/gh attribution shims when attribution is disabled', async () => {
|
||||
@@ -1164,7 +1166,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir)
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
|
||||
@@ -1193,7 +1195,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
const env = daemonSpawn.mock.calls.at(-1)![0].env
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir)
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
|
||||
@@ -1799,7 +1801,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
|
||||
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir)
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
|
||||
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
@@ -1829,7 +1831,7 @@ describe('registerPtyHandlers', () => {
|
||||
enableGitHubAttribution: true
|
||||
}))
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir)
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
|
||||
@@ -1850,7 +1852,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
|
||||
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir)
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
|
||||
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
@@ -1967,7 +1969,7 @@ describe('registerPtyHandlers', () => {
|
||||
enableGitHubAttribution: false
|
||||
}))
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir)
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('does not mutate the caller-provided args.env on the daemon path', async () => {
|
||||
@@ -5810,9 +5812,11 @@ describe('registerPtyHandlers', () => {
|
||||
const env = spawnCall[2].env as Record<string, string>
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
|
||||
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
|
||||
expect(env.WSLENV?.split(':')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
'ORCA_USER_DATA_PATH/p',
|
||||
'ORCA_AGENT_HOOK_PORT/u',
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
'ORCA_OMP_SOURCE_AGENT_DIR/p',
|
||||
@@ -5822,6 +5826,42 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
preAllocateHandleForPty: vi.fn(() => 'term_wsl'),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe',
|
||||
env: {
|
||||
ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
const env = spawnCall[2].env as Record<string, string>
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
|
||||
})
|
||||
|
||||
describe('Windows UTF-8 code page', () => {
|
||||
let originalPlatform: string
|
||||
let originalComspec: string | undefined
|
||||
|
||||
+7
-8
@@ -909,15 +909,14 @@ export function buildPtyHostEnv(
|
||||
baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath
|
||||
}
|
||||
|
||||
// Why: in dev mode the `orca` CLI defaults to the production userData
|
||||
// path, which routes status updates to the packaged Orca instead of this
|
||||
// dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from
|
||||
// agents running inside dev terminals reach the correct runtime. We also
|
||||
// prepend the dev CLI launcher directory to PATH so `orca` resolves to
|
||||
// the dev build (which supports ORCA_USER_DATA_PATH) instead of the
|
||||
// production binary at /usr/local/bin/orca.
|
||||
if (!opts.isPackaged) {
|
||||
// Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance.
|
||||
if (opts.isWsl) {
|
||||
baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath
|
||||
} else if (!opts.isPackaged) {
|
||||
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
|
||||
}
|
||||
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
|
||||
if (!opts.isPackaged) {
|
||||
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
|
||||
const inheritedPath = readInheritedPath(baseEnv)
|
||||
// Why: avoid a trailing delimiter when PATH is empty — some shells
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
getPathMock,
|
||||
listEnvironmentsMock,
|
||||
callRuntimeEnvironmentMock,
|
||||
registerCliHandlersMock,
|
||||
registerPreflightHandlersMock,
|
||||
registerClaudeUsageHandlersMock,
|
||||
@@ -57,6 +60,9 @@ const {
|
||||
registerEmulatorFrameStreamHandlersMock,
|
||||
registerEmulatorVideoStreamHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/test/user-data'),
|
||||
listEnvironmentsMock: vi.fn(() => []),
|
||||
callRuntimeEnvironmentMock: vi.fn(),
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
registerClaudeUsageHandlersMock: vi.fn(),
|
||||
@@ -114,6 +120,20 @@ const {
|
||||
registerEmulatorVideoStreamHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
listEnvironments: listEnvironmentsMock
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: callRuntimeEnvironmentMock
|
||||
}))
|
||||
|
||||
vi.mock('./onboarding', () => ({
|
||||
registerOnboardingHandlers: registerOnboardingHandlersMock
|
||||
}))
|
||||
@@ -326,6 +346,11 @@ import { registerCoreHandlers } from './register-core-handlers'
|
||||
|
||||
describe('registerCoreHandlers', () => {
|
||||
beforeEach(() => {
|
||||
getPathMock.mockReset()
|
||||
getPathMock.mockReturnValue('/test/user-data')
|
||||
listEnvironmentsMock.mockReset()
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
callRuntimeEnvironmentMock.mockReset()
|
||||
registerCliHandlersMock.mockReset()
|
||||
registerPreflightHandlersMock.mockReset()
|
||||
registerClaudeUsageHandlersMock.mockReset()
|
||||
@@ -382,7 +407,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerEmulatorVideoStreamHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes the store through to handler registrars that need it', () => {
|
||||
it('passes the store through to handler registrars that need it', async () => {
|
||||
const store = { marker: 'store' }
|
||||
const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null }
|
||||
const stats = { marker: 'stats' }
|
||||
@@ -415,6 +440,14 @@ describe('registerCoreHandlers', () => {
|
||||
{ getAdditionalAiVaultCodexHomePaths, onBeforeRelaunch }
|
||||
)
|
||||
|
||||
const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0]
|
||||
expect(aiVaultOptions).toBeDefined()
|
||||
|
||||
callRuntimeEnvironmentMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { sessions: 'bad-shape' }
|
||||
})
|
||||
|
||||
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
|
||||
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
|
||||
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
|
||||
@@ -451,9 +484,14 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
|
||||
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
|
||||
})
|
||||
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths,
|
||||
getActiveRuntimeAiVaultHostInfos: expect.any(Function),
|
||||
scanRuntimeAiVaultSessions: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([])
|
||||
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
|
||||
expect(registerCliHandlersMock).toHaveBeenCalled()
|
||||
expect(registerPreflightHandlersMock).toHaveBeenCalled()
|
||||
@@ -466,6 +504,40 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerBrowserHandlersMock).toHaveBeenCalled()
|
||||
expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled()
|
||||
expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
||||
await expect(
|
||||
aiVaultOptions.scanRuntimeAiVaultSessions(
|
||||
'env-123',
|
||||
{
|
||||
limit: 10,
|
||||
scopePaths: ['/workspace']
|
||||
},
|
||||
{ timeoutMs: 3000 }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
sessions: [],
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
executionHostId: 'runtime:env-123',
|
||||
agent: 'codex',
|
||||
path: 'env-123',
|
||||
message: expect.stringContaining('Invalid aiVault.listSessions response')
|
||||
})
|
||||
],
|
||||
scannedAt: expect.any(String)
|
||||
})
|
||||
expect(callRuntimeEnvironmentMock).toHaveBeenCalledWith(
|
||||
'/test/user-data',
|
||||
'env-123',
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
limit: 10,
|
||||
force: undefined,
|
||||
scopePaths: ['/workspace'],
|
||||
executionHostId: 'runtime:env-123'
|
||||
},
|
||||
3000
|
||||
)
|
||||
})
|
||||
|
||||
it('only registers IPC handlers once but always updates web contents id', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { registerAppHandlers } from './app'
|
||||
import { registerCliHandlers } from './cli'
|
||||
import { registerPreflightHandlers } from './preflight'
|
||||
@@ -69,6 +70,10 @@ import type { AutomationService } from '../automations/service'
|
||||
import type { AgentAwakeService } from '../agent-awake-service'
|
||||
import type { CrashReportStore } from '../crash-reporting/crash-report-store'
|
||||
import type { KeybindingService } from '../keybindings/keybinding-service'
|
||||
import {
|
||||
getSavedRuntimeAiVaultHostInfos,
|
||||
scanRuntimeAiVaultSessions
|
||||
} from '../ai-vault/runtime-session-scanner'
|
||||
|
||||
let registered = false
|
||||
|
||||
@@ -171,7 +176,11 @@ export function registerCoreHandlers(
|
||||
registerRuntimeEnvironmentHandlers(store)
|
||||
registerEphemeralVmHandlers(store)
|
||||
registerAiVaultHandlers({
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
|
||||
getActiveRuntimeAiVaultHostInfos: () =>
|
||||
getSavedRuntimeAiVaultHostInfos(app.getPath('userData')),
|
||||
scanRuntimeAiVaultSessions: async (environmentId, args, options) =>
|
||||
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options)
|
||||
})
|
||||
registerNativeChatHandlers()
|
||||
registerClipboardHandlers(store)
|
||||
|
||||
+109
-3
@@ -50,7 +50,8 @@ const {
|
||||
onRequest: vi.fn().mockReturnValue(() => {}),
|
||||
onDispose: vi.fn().mockReturnValue(() => {}),
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
notify: vi.fn()
|
||||
notify: vi.fn(),
|
||||
probeLiveness: vi.fn().mockResolvedValue(false)
|
||||
},
|
||||
mockPtyProvider: {
|
||||
onData: vi.fn(),
|
||||
@@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => {
|
||||
mockMux.isDisposed.mockReset().mockReturnValue(false)
|
||||
mockMux.onNotification.mockReset()
|
||||
mockMux.onDispose.mockReset().mockReturnValue(() => {})
|
||||
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
|
||||
mockPtyProvider.onData.mockReset()
|
||||
mockPtyProvider.onExit.mockReset()
|
||||
mockPtyProvider.onReplay.mockReset()
|
||||
@@ -778,6 +780,39 @@ describe('SSH IPC handlers', () => {
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
|
||||
})
|
||||
|
||||
it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => {
|
||||
const runtime = {
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
notifySshStateChanged: vi.fn()
|
||||
}
|
||||
registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never)
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue({})
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
// Why: paired remote clients only learn SSH state through this hook —
|
||||
// without it their reconnect overlays never clear (STA-1468).
|
||||
expect(runtime.notifySshStateChanged).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
expect.objectContaining({ targetId: 'ssh-1', status: 'connected' })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves active port forwards and live connections across handler re-registration', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
@@ -1368,7 +1403,7 @@ describe('SSH IPC handlers', () => {
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => {
|
||||
it('reconnects on system resume when the relay liveness probe fails', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
@@ -1386,6 +1421,7 @@ describe('SSH IPC handlers', () => {
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(false)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
@@ -1394,7 +1430,77 @@ describe('SSH IPC handlers', () => {
|
||||
|
||||
resumeListener()
|
||||
|
||||
expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')
|
||||
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
|
||||
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
|
||||
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('skips reconnect on system resume when the relay link is still alive', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(true)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
|
||||
expect(resumeListener).toBeTypeOf('function')
|
||||
|
||||
resumeListener()
|
||||
|
||||
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1))
|
||||
// Let the async resume handler settle before asserting no teardown happened.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reconnect after resume when the target was disconnected during the probe', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(false)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
|
||||
expect(resumeListener).toBeTypeOf('function')
|
||||
|
||||
resumeListener()
|
||||
// Why: the probe window is seconds long; a user disconnect during it must
|
||||
// win — reconnecting afterwards would resurrect the torn-down target.
|
||||
mockConnectionManager.getConnection.mockReturnValue(undefined)
|
||||
|
||||
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends active relay grace while the system is suspending', async () => {
|
||||
|
||||
+59
-14
@@ -90,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un
|
||||
return registeredGetSshState?.(targetId)
|
||||
}
|
||||
|
||||
/** Public targets for runtime RPC clients — same list the desktop renderer gets. */
|
||||
export function listRegisteredSshTargets(): SshTarget[] {
|
||||
return sshStore?.listTargets() ?? []
|
||||
}
|
||||
|
||||
/** Removed-target id → last known label, for ghost-host display on paired clients. */
|
||||
export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
|
||||
return sshStore?.listRemovedTargetLabels() ?? {}
|
||||
}
|
||||
|
||||
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
|
||||
if (!connectionManager) {
|
||||
return
|
||||
@@ -235,13 +245,14 @@ function broadcastSshState(
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
return
|
||||
}
|
||||
const enrichedState = withSshRemotePlatform(targetId, state)
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('ssh:state-changed', {
|
||||
targetId,
|
||||
state: withSshRemotePlatform(targetId, state)
|
||||
})
|
||||
win.webContents.send('ssh:state-changed', { targetId, state: enrichedState })
|
||||
}
|
||||
// Why: paired remote clients have no ssh:state-changed IPC; without this
|
||||
// their terminals keep a stale reconnect overlay after the host connects.
|
||||
currentRuntime?.notifySshStateChanged?.(targetId, enrichedState)
|
||||
}
|
||||
|
||||
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
|
||||
@@ -431,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: macOS can resume the process before the network stack is back up, so
|
||||
// a failed first probe gets one retry before the link is declared dead (#7773).
|
||||
const RESUME_PROBE_TIMEOUT_MS = 5_000
|
||||
const RESUME_PROBE_ATTEMPTS = 2
|
||||
|
||||
async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise<boolean> {
|
||||
const mux = session.getMux()
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return false
|
||||
}
|
||||
for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) {
|
||||
if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function registerPowerMonitorReconnect(): void {
|
||||
powerMonitorUnsubscribe?.()
|
||||
const onSuspend = (): void => {
|
||||
@@ -439,19 +468,35 @@ function registerPowerMonitorReconnect(): void {
|
||||
}
|
||||
}
|
||||
const onResume = (): void => {
|
||||
for (const targetId of activeSessions.keys()) {
|
||||
const conn = connectionManager?.getConnection(targetId)
|
||||
for (const [targetId, session] of activeSessions) {
|
||||
const manager = connectionManager
|
||||
const conn = manager?.getConnection(targetId)
|
||||
if (!conn) {
|
||||
continue
|
||||
}
|
||||
const reconnect = connectionManager?.reconnect(targetId)
|
||||
void reconnect?.catch((err) => {
|
||||
console.warn(
|
||||
`[ssh] Failed to reconnect ${targetId} after system resume: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
})
|
||||
void (async () => {
|
||||
// Why: unconditional reconnect on every wake tore down live sessions
|
||||
// and flashed the reconnect overlay (#7773). Only reconnect targets
|
||||
// whose relay link actually died during sleep.
|
||||
if (await isRelayLinkAliveAfterResume(session)) {
|
||||
return
|
||||
}
|
||||
// Why: the probe can take ~10s. If the user disconnected or the
|
||||
// session/connection was replaced meanwhile, reconnecting would
|
||||
// resurrect a connection that was intentionally torn down.
|
||||
if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await manager?.reconnect(targetId)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ssh] Failed to reconnect ${targetId} after system resume: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
powerMonitor.on('suspend', onSuspend)
|
||||
|
||||
@@ -2988,6 +2988,16 @@ describe('Store', () => {
|
||||
expect(updated?.projectGroupOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('updates repo execution host identity', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1' }))
|
||||
|
||||
const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' })
|
||||
|
||||
expect(updated?.executionHostId).toBe('runtime:env-1')
|
||||
expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1')
|
||||
})
|
||||
|
||||
it('getRepo returns undefined for nonexistent id', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getRepo('nonexistent')).toBeUndefined()
|
||||
@@ -3188,6 +3198,140 @@ describe('Store', () => {
|
||||
expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new')
|
||||
})
|
||||
|
||||
it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }))
|
||||
store.setWorkspaceSession({
|
||||
activeRepoId: 'r1',
|
||||
activeWorktreeId: 'r1::/wt',
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {},
|
||||
remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' },
|
||||
activeConnectionIdsAtShutdown: ['ssh-old']
|
||||
})
|
||||
store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' })
|
||||
store.updateUI({
|
||||
workspaceHostScope: 'ssh:ssh-old',
|
||||
visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'],
|
||||
workspaceHostOrder: ['ssh:ssh-old', 'local']
|
||||
})
|
||||
|
||||
store.reassignSshTargetId('ssh-old', 'ssh-new')
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
const session = reloaded.getWorkspaceSession()
|
||||
expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2')
|
||||
expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' })
|
||||
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new'])
|
||||
expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1)
|
||||
expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0)
|
||||
const ui = reloaded.getUI()
|
||||
expect(ui.workspaceHostScope).toBe('ssh:ssh-new')
|
||||
expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new'])
|
||||
expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local'])
|
||||
})
|
||||
|
||||
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {}
|
||||
},
|
||||
'ssh:ssh-old'
|
||||
)
|
||||
|
||||
store.reassignSshTargetId('ssh-old', 'ssh-new')
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
// Old-key partition is gone; the re-keyed one carries migrated pty ids.
|
||||
expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
|
||||
expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe(
|
||||
'ssh:ssh-new@@pty-9'
|
||||
)
|
||||
})
|
||||
|
||||
it('reassignSshTargetId keeps the live partition when both host keys exist', async () => {
|
||||
const store = await createStore()
|
||||
const baseSession = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
terminalLayoutsByTabId: {}
|
||||
}
|
||||
store.setWorkspaceSession(
|
||||
{ ...baseSession, tabsByWorktree: { 'r1::/dead': [] } },
|
||||
'ssh:ssh-old'
|
||||
)
|
||||
store.setWorkspaceSession(
|
||||
{ ...baseSession, tabsByWorktree: { 'r1::/live': [] } },
|
||||
'ssh:ssh-new'
|
||||
)
|
||||
|
||||
store.reassignSshTargetId('ssh-old', 'ssh-new')
|
||||
|
||||
expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
|
||||
expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] })
|
||||
})
|
||||
|
||||
it('reassignSshTargetId re-points an independent provisioned host setup', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo({
|
||||
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
|
||||
upstream: { owner: 'stablyai', repo: 'cloud-project' }
|
||||
})
|
||||
store.createProjectHostSetup({
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'ssh:ssh-old',
|
||||
setupId: 'cloud-project::ssh-old',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
|
||||
// Meta-only re-adoption (no repo pinned to the old id) must still migrate
|
||||
// the provisioned setup, or new worktrees would be born on a dead host id.
|
||||
store.reassignSshTargetId('ssh-old', 'ssh-new')
|
||||
|
||||
const setups = store.getProjectHostSetups()
|
||||
const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old')
|
||||
expect(provisioned?.hostId).toBe('ssh:ssh-new')
|
||||
})
|
||||
|
||||
it('reassignSshTargetId drops a stale setup when the new host already has one', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo({
|
||||
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
|
||||
upstream: { owner: 'stablyai', repo: 'cloud-project' }
|
||||
})
|
||||
store.createProjectHostSetup({
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'ssh:ssh-old',
|
||||
setupId: 'setup-old',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
store.createProjectHostSetup({
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'ssh:ssh-new',
|
||||
setupId: 'setup-new',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
|
||||
store.reassignSshTargetId('ssh-old', 'ssh-new')
|
||||
|
||||
const setups = store.getProjectHostSetups()
|
||||
expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined()
|
||||
expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new')
|
||||
})
|
||||
|
||||
// ── 7. updateRepo ──────────────────────────────────────────────────
|
||||
|
||||
it('updateRepo modifies the repo in place', async () => {
|
||||
|
||||
+68
-5
@@ -113,6 +113,10 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../shared/execution-host'
|
||||
import { toRelaySshPtyId } from './providers/ssh-pty-id'
|
||||
import {
|
||||
migrateUiHostScopeSshTargetId,
|
||||
migrateWorkspaceSessionSshTargetId
|
||||
} from './ssh/ssh-target-id-migration'
|
||||
import { isWslUncPath } from '../shared/wsl-paths'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
@@ -4272,6 +4276,7 @@ export class Store {
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
| 'kind'
|
||||
| 'executionHostId'
|
||||
| 'symlinkPaths'
|
||||
| 'issueSourcePreference'
|
||||
| 'forkSyncMode'
|
||||
@@ -6130,13 +6135,71 @@ export class Store {
|
||||
metaChanged = true
|
||||
}
|
||||
}
|
||||
// Why: repo-row rewrites can affect host-setup compatibility, but meta-only
|
||||
// rewrites cannot — keep that sync under the repo gate. Persist whenever
|
||||
// either repos OR metas changed, so meta-only re-points aren't lost on quit.
|
||||
if (repoCount > 0) {
|
||||
// Why: the old id also survives in session pty ids, the startup reconnect
|
||||
// list, sleeping-agent records, host setups, host-scope UI, and pty leases;
|
||||
// any un-migrated carrier later throws `SSH target not found` (STA-1468).
|
||||
let carrierChanged = migrateWorkspaceSessionSshTargetId(
|
||||
this.state.workspaceSession,
|
||||
oldTargetId,
|
||||
newTargetId
|
||||
)
|
||||
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
|
||||
if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) {
|
||||
carrierChanged = true
|
||||
}
|
||||
}
|
||||
// Why: partitions are read by host id, so one stored under the removed id
|
||||
// would be orphaned. No writer keys partitions by ssh host today, but the
|
||||
// schema tolerates it — re-key rather than strand it. If the new key
|
||||
// already has a partition, that one is live; drop the dead old one.
|
||||
const partitions = this.state.workspaceSessionsByHostId
|
||||
const oldPartition = partitions?.[oldHostId]
|
||||
if (partitions && oldPartition) {
|
||||
delete partitions[oldHostId]
|
||||
partitions[newHostId] ??= oldPartition
|
||||
carrierChanged = true
|
||||
}
|
||||
if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) {
|
||||
carrierChanged = true
|
||||
}
|
||||
for (const lease of this.state.sshRemotePtyLeases ?? []) {
|
||||
if (lease.targetId === oldTargetId) {
|
||||
lease.targetId = newTargetId
|
||||
carrierChanged = true
|
||||
}
|
||||
}
|
||||
let setupsChanged = false
|
||||
const keptSetups: ProjectHostSetup[] = []
|
||||
for (const setup of this.state.projectHostSetups) {
|
||||
if (setup.hostId !== oldHostId) {
|
||||
keptSetups.push(setup)
|
||||
continue
|
||||
}
|
||||
const duplicate = this.state.projectHostSetups.some(
|
||||
(entry) =>
|
||||
entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId
|
||||
)
|
||||
// Why: a setup already exists for the re-added host — the old row is a
|
||||
// stale ghost that would violate the (projectId, hostId) uniqueness.
|
||||
if (duplicate) {
|
||||
setupsChanged = true
|
||||
continue
|
||||
}
|
||||
setup.hostId = newHostId
|
||||
setup.updatedAt = Date.now()
|
||||
keptSetups.push(setup)
|
||||
setupsChanged = true
|
||||
}
|
||||
if (setupsChanged) {
|
||||
this.state.projectHostSetups = keptSetups
|
||||
}
|
||||
// Why: repo-row and host-setup rewrites can affect host-setup compatibility,
|
||||
// but meta-only rewrites cannot — keep that sync under this gate. Persist
|
||||
// whenever anything changed, so partial re-points aren't lost on quit.
|
||||
if (repoCount > 0 || setupsChanged) {
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
}
|
||||
if (repoCount > 0 || metaChanged) {
|
||||
if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) {
|
||||
this.scheduleSave()
|
||||
}
|
||||
return repoCount
|
||||
|
||||
@@ -50,6 +50,10 @@ export type ShellReadySignal = {
|
||||
// ── Shell wrapper files ─────────────────────────────────────────────
|
||||
|
||||
function getShellReadyWrapperRoot(): string {
|
||||
// Why: this instance's userData must win over an inherited
|
||||
// ORCA_USER_DATA_PATH (Orca launched from another Orca's terminal), so the
|
||||
// wrapper writer root always matches the root buildPtyHostEnv hands to
|
||||
// WSL children.
|
||||
const userDataPath = app?.getPath?.('userData') ?? process.env.ORCA_USER_DATA_PATH ?? tmpdir()
|
||||
return `${userDataPath}/shell-ready`
|
||||
}
|
||||
@@ -64,8 +68,8 @@ function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): s
|
||||
]
|
||||
}
|
||||
|
||||
function shellReadyWrappersExist(): boolean {
|
||||
return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path))
|
||||
function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean {
|
||||
return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path))
|
||||
}
|
||||
|
||||
// Why: if our own process inherited ZDOTDIR from a parent shell that was
|
||||
@@ -288,16 +292,12 @@ fi
|
||||
`
|
||||
}
|
||||
|
||||
function ensureShellReadyWrappers(): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) {
|
||||
export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void {
|
||||
if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) {
|
||||
return
|
||||
}
|
||||
didEnsureShellReadyWrappers = true
|
||||
|
||||
const root = getShellReadyWrapperRoot()
|
||||
const zshDir = `${root}/zsh`
|
||||
const bashDir = `${root}/bash`
|
||||
|
||||
@@ -365,6 +365,13 @@ ${getZshFinalZdotdirRestoreBlock()}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureShellReadyWrappers(): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
ensureShellReadyWrappersAt()
|
||||
}
|
||||
|
||||
// ── Shell launch config ─────────────────────────────────────────────
|
||||
|
||||
export type ShellReadyLaunchConfig = {
|
||||
|
||||
@@ -417,7 +417,11 @@ describe('SshFilesystemProvider', () => {
|
||||
it('listFiles sends fs.listFiles request', async () => {
|
||||
mux.request.mockResolvedValue(['src/index.ts', 'package.json'])
|
||||
const result = await provider.listFiles('/home/user/project')
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(result).toEqual(['src/index.ts', 'package.json'])
|
||||
})
|
||||
|
||||
@@ -426,16 +430,35 @@ describe('SshFilesystemProvider', () => {
|
||||
await provider.listFiles('/home/user/project', {
|
||||
excludePaths: ['/home/user/project/worktrees/b']
|
||||
})
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
|
||||
rootPath: '/home/user/project',
|
||||
excludePaths: ['/home/user/project/worktrees/b']
|
||||
})
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{
|
||||
rootPath: '/home/user/project',
|
||||
excludePaths: ['/home/user/project/worktrees/b']
|
||||
},
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('listFiles omits excludePaths when empty', async () => {
|
||||
mux.request.mockResolvedValue([])
|
||||
await provider.listFiles('/home/user/project', { excludePaths: [] })
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => {
|
||||
mux.request.mockResolvedValue([])
|
||||
const controller = new AbortController()
|
||||
await provider.listFiles('/home/user/project', { signal: controller.signal })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
})
|
||||
|
||||
describe('watch', () => {
|
||||
|
||||
@@ -283,12 +283,20 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
return (await this.mux.request('fs.search', opts)) as SearchResult
|
||||
}
|
||||
|
||||
async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise<string[]> {
|
||||
async listFiles(
|
||||
rootPath: string,
|
||||
options?: { excludePaths?: string[]; signal?: AbortSignal }
|
||||
): Promise<string[]> {
|
||||
const params: Record<string, unknown> = { rootPath }
|
||||
if (options?.excludePaths && options.excludePaths.length > 0) {
|
||||
params.excludePaths = options.excludePaths
|
||||
}
|
||||
return (await this.mux.request('fs.listFiles', params)) as string[]
|
||||
// Why #7721: the signal lets a workspace switch send rpc.cancel so the
|
||||
// relay aborts the full-tree scan instead of stacking abandoned scans
|
||||
// that starve interactive fs.readDir/fs.stat on the shared SSH channel.
|
||||
return (await this.mux.request('fs.listFiles', params, {
|
||||
signal: options?.signal
|
||||
})) as string[]
|
||||
}
|
||||
|
||||
async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> {
|
||||
|
||||
@@ -197,7 +197,10 @@ export type IFilesystemProvider = {
|
||||
copy(source: string, destination: string): Promise<void>
|
||||
realpath(filePath: string): Promise<string>
|
||||
search(opts: SearchOptions): Promise<SearchResult>
|
||||
listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise<string[]>
|
||||
listFiles(
|
||||
rootPath: string,
|
||||
options?: { excludePaths?: string[]; signal?: AbortSignal }
|
||||
): Promise<string[]>
|
||||
scanWorkspaceSpace?(
|
||||
rootPath: string,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
@@ -17,6 +20,24 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLaunchArgs', () => {
|
||||
let previousUserDataPath: string | undefined
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-'))
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
@@ -201,6 +222,27 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
it('materializes shell-ready wrappers before building WSL shell args', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true)
|
||||
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
|
||||
|
||||
// Why: the point of materializing wrappers for WSL is that a typed `omp`
|
||||
// picks up Orca's status extension; pin that shim end to end.
|
||||
const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
||||
const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
for (const wrapperFile of [bashRcfile, zshLogin]) {
|
||||
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
|
||||
expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }')
|
||||
}
|
||||
})
|
||||
|
||||
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { ensureShellReadyWrappersAt } from './local-pty-shell-ready'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
@@ -94,6 +95,7 @@ function getPowerShellEncodedCommand(startupCommand?: string): {
|
||||
* Builds wsl.exe arguments that enter the target directory through the distro shell.
|
||||
*/
|
||||
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
ensureShellReadyWrappersAt()
|
||||
const setupCommand = [
|
||||
`cd ${quotePosixShell(linuxCwd)}`,
|
||||
'export PATH="$HOME/.local/bin:$PATH"',
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
it('marks OMP status and hook env for Windows to WSL import', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_TERMINAL_HANDLE: 'term_wsl',
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
|
||||
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
@@ -36,6 +37,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
|
||||
@@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
|
||||
|
||||
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
const entries = parseWslenvEntries(env.WSLENV)
|
||||
// Why: wsl.exe only imports selected Windows env vars. Agent status in WSL
|
||||
// needs both the pane identity and the hook/OMP coordinates at process start.
|
||||
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
|
||||
const passthroughEntries = [
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
'ORCA_USER_DATA_PATH/p',
|
||||
'ORCA_PANE_KEY/u',
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
buildAgentPromptPasteBytes
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
@@ -5255,6 +5257,406 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps existing-folder imports split by runtime host on the same normalized path', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-'))
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
|
||||
const first = await runtime.setupProjectExistingFolder({
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:env-1',
|
||||
path: tempRoot,
|
||||
kind: 'git',
|
||||
setupMethod: 'imported-existing-folder'
|
||||
})
|
||||
const second = await runtime.setupProjectExistingFolder({
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:env-2',
|
||||
path: tempRoot,
|
||||
kind: 'git',
|
||||
setupMethod: 'imported-existing-folder'
|
||||
})
|
||||
|
||||
expect(repos).toHaveLength(2)
|
||||
expect(repos).toEqual([
|
||||
expect.objectContaining({
|
||||
path: tempRoot,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: tempRoot,
|
||||
executionHostId: 'runtime:env-2'
|
||||
})
|
||||
])
|
||||
expect(first.repo).toMatchObject({
|
||||
path: tempRoot,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(first.setup).toMatchObject({
|
||||
repoId: first.repo.id,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
expect(second.repo).toMatchObject({
|
||||
path: tempRoot,
|
||||
executionHostId: 'runtime:env-2'
|
||||
})
|
||||
expect(second.setup).toMatchObject({
|
||||
repoId: second.repo.id,
|
||||
hostId: 'runtime:env-2'
|
||||
})
|
||||
expect(first.repo.id).not.toBe(second.repo.id)
|
||||
expect(first.setup.repoId).not.toBe(second.setup.repoId)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps path-only runtime addRepo reuse working when a host-qualified repo already exists', async () => {
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-runtime-1',
|
||||
path: '/tmp/runtime-shared',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const repo = await runtime.addRepo('/tmp/runtime-shared', 'folder')
|
||||
|
||||
expect(repo).toMatchObject({
|
||||
id: 'repo-runtime-1',
|
||||
path: '/tmp/runtime-shared',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(repos).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not hijack a legacy SSH repo at the same path into a runtime host', async () => {
|
||||
// A legacy SSH repo resolves to `ssh:<connectionId>` even with a null executionHostId,
|
||||
// so a same-path runtime import must create a new repo instead of adopting it.
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-ssh-1',
|
||||
path: '/workspace',
|
||||
displayName: 'workspace',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder',
|
||||
connectionId: 'ssh-target-1'
|
||||
}
|
||||
]
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const repo = await runtime.addRepo('/workspace', 'folder', 'runtime:env-1')
|
||||
|
||||
expect(repos).toHaveLength(2)
|
||||
expect(repo.id).not.toBe('repo-ssh-1')
|
||||
expect(repo).toMatchObject({ path: '/workspace', executionHostId: 'runtime:env-1' })
|
||||
// The legacy SSH repo must be untouched (no executionHostId stamped onto it).
|
||||
expect(repos[0]).toMatchObject({ id: 'repo-ssh-1', connectionId: 'ssh-target-1' })
|
||||
expect(repos[0]).not.toHaveProperty('executionHostId')
|
||||
})
|
||||
|
||||
it('only a runtime host adopts an unstamped repo; local/ssh imports never stamp it', async () => {
|
||||
// A genuine local repo is indistinguishable from a legacy runtime repo (both have null
|
||||
// executionHostId and connectionId). Only a runtime host may backfill it — a local or ssh
|
||||
// import at the same path must leave it untouched rather than re-attribute it to that host.
|
||||
for (const importHostId of ['local', 'ssh:ssh-target-9'] as const) {
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-local-1',
|
||||
path: '/workspace',
|
||||
displayName: 'workspace',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder'
|
||||
}
|
||||
]
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const repo = await runtime.addRepo('/workspace', 'folder', importHostId)
|
||||
|
||||
// The matched repo is returned unchanged — no new repo, no executionHostId stamped.
|
||||
expect(repos).toHaveLength(1)
|
||||
expect(repo.id).toBe('repo-local-1')
|
||||
expect(repos[0]).not.toHaveProperty('executionHostId')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps project clone setup on the cloned host-qualified repo', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
})
|
||||
return proc as never
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const result = await runtime.setupProjectClone({
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:env-1',
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
|
||||
expect(repos).toHaveLength(1)
|
||||
expect(result.repo).toMatchObject({
|
||||
path: clonePath,
|
||||
executionHostId: 'runtime:env-1',
|
||||
projectHostSetupMethod: 'cloned'
|
||||
})
|
||||
expect(result.setup).toMatchObject({
|
||||
repoId: result.repo.id,
|
||||
hostId: 'runtime:env-1',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts public clone repos into host-qualified project setup', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
})
|
||||
return proc as never
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const cloned = await runtime.cloneRepo('https://example.com/orca.git', destination)
|
||||
expect(cloned).toMatchObject({
|
||||
path: clonePath
|
||||
})
|
||||
expect(cloned).not.toHaveProperty('executionHostId')
|
||||
|
||||
const result = await runtime.setupProjectExistingFolder({
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:env-1',
|
||||
path: clonePath,
|
||||
kind: 'git',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
|
||||
expect(repos).toHaveLength(1)
|
||||
expect(result.repo).toMatchObject({
|
||||
id: cloned.id,
|
||||
path: clonePath,
|
||||
executionHostId: 'runtime:env-1',
|
||||
projectHostSetupMethod: 'cloned'
|
||||
})
|
||||
expect(result.setup).toMatchObject({
|
||||
repoId: cloned.id,
|
||||
hostId: 'runtime:env-1',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps project clone repos split by runtime host on the same clone path', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-host-a',
|
||||
path: clonePath,
|
||||
displayName: 'orca',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
repos.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
repos[index] = { ...repos[index], ...updates }
|
||||
return repos[index] as never
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
})
|
||||
return proc as never
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const result = await runtime.setupProjectClone({
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:env-2',
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
|
||||
expect(repos).toHaveLength(2)
|
||||
expect(repos[0]).toMatchObject({
|
||||
id: 'repo-host-a',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(result.repo).toMatchObject({
|
||||
path: clonePath,
|
||||
executionHostId: 'runtime:env-2'
|
||||
})
|
||||
expect(result.repo.id).not.toBe('repo-host-a')
|
||||
expect(result.setup).toMatchObject({
|
||||
repoId: result.repo.id,
|
||||
hostId: 'runtime:env-2',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
const colorStore = {
|
||||
@@ -26495,4 +26897,226 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(preDaemonProvider.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
describe('stale terminal handle resolution (#7718)', () => {
|
||||
function syncSingleTerminalGraph(runtime: OrcaRuntimeService, ptyId: string): void {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Terminal 1',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId,
|
||||
paneTitle: 'Terminal 1'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal 1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Why: session-tab mirrors travel as PTY handles, but client-created
|
||||
// terminals (terminal.create) hold LEAF handles — the kind that can
|
||||
// diverge from the pane's current PTY across reconnects. Issue one
|
||||
// directly from the leaf record to pin the resolver semantics.
|
||||
function issueLeafHandle(runtime: OrcaRuntimeService, ptyId: string): string {
|
||||
const internals = runtime as unknown as {
|
||||
leaves: Map<string, { ptyId: string | null }>
|
||||
issueHandle: (leaf: unknown) => string
|
||||
}
|
||||
const leaf = Array.from(internals.leaves.values()).find(
|
||||
(candidate) => candidate.ptyId === ptyId
|
||||
)
|
||||
if (!leaf) {
|
||||
throw new Error('expected leaf record')
|
||||
}
|
||||
return internals.issueHandle(leaf)
|
||||
}
|
||||
|
||||
it('errors with terminal_handle_stale instead of adopting a replacement PTY', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.attachWindow(1)
|
||||
syncSingleTerminalGraph(runtime, 'pty-a')
|
||||
const handle = issueLeafHandle(runtime, 'pty-a')
|
||||
|
||||
// Simulate the pane's PTY being replaced while the remote client still
|
||||
// holds the old handle (the record missed the swap, e.g. across a
|
||||
// renderer graph reload that preserved live PTY records).
|
||||
const internals = runtime as unknown as {
|
||||
leaves: Map<string, { ptyId: string | null }>
|
||||
}
|
||||
for (const leaf of internals.leaves.values()) {
|
||||
if (leaf.ptyId === 'pty-a') {
|
||||
leaf.ptyId = 'pty-b'
|
||||
}
|
||||
}
|
||||
|
||||
// The unguarded resolver silently adopts the new PTY — the misroute.
|
||||
expect(runtime.resolveLeafForHandle(handle)).toEqual({ ptyId: 'pty-b' })
|
||||
// The guarded resolver surfaces the staleness so clients can re-derive.
|
||||
expect(() => runtime.resolveLiveLeafForHandle(handle)).toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('lets a handle issued before its first PTY adopt that PTY without erroring', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.attachWindow(1)
|
||||
syncSingleTerminalGraph(runtime, 'pty-a')
|
||||
const handle = issueLeafHandle(runtime, 'pty-a')
|
||||
|
||||
// Simulate the mobile pre-spawn flow: the handle record predates the
|
||||
// PTY (ptyId null); the pane's first PTY must still resolve.
|
||||
const internals = runtime as unknown as {
|
||||
handles: Map<string, { ptyId: string | null }>
|
||||
}
|
||||
const record = internals.handles.get(handle)
|
||||
if (!record) {
|
||||
throw new Error('expected handle record')
|
||||
}
|
||||
record.ptyId = null
|
||||
|
||||
expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile terminal create resilience (#7718)', () => {
|
||||
it('cancels the surface wait without rolling back when the client connection dies', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closeTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal))
|
||||
const abort = new AbortController()
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-abort', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
webContents.send = send
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
|
||||
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
signal: abort.signal
|
||||
})
|
||||
const settled = create.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: Error) => ({ ok: false as const, error })
|
||||
)
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The client's socket dies mid-wait. The create must settle right away
|
||||
// (not run down the 10s surface timeout) and must NOT close the tab —
|
||||
// the renderer spawned a real terminal the host user can see.
|
||||
abort.abort()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const outcome = await settled
|
||||
|
||||
expect(outcome.ok).toBe(false)
|
||||
if (outcome.ok === false) {
|
||||
expect(outcome.error.message).toBe('client_disconnected')
|
||||
}
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a mobile-created terminal alive when its live shell has no registered pane key', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closeTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal))
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-stall', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
webContents.send = send
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
|
||||
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false
|
||||
})
|
||||
const settled = create.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: Error) => ({ ok: false as const, error })
|
||||
)
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A live shell backs the tab, but its leaf id is not a terminal UUID
|
||||
// so no pane key registers: the surface rescue can't publish it, and
|
||||
// the surface itself never publishes (stalled renderer publication).
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-stall',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-stall',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
const outcome = await settled
|
||||
|
||||
// The create still fails (nothing to return), but the timeout must not
|
||||
// kill the live terminal — that is the "tab dies after ~10s" symptom.
|
||||
expect(outcome.ok).toBe(false)
|
||||
if (outcome.ok === false) {
|
||||
expect(outcome.error.message).toContain('Timed out')
|
||||
}
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,9 +126,11 @@ import type {
|
||||
WorkspaceSessionState,
|
||||
DirEntry
|
||||
} from '../../shared/types'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
|
||||
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
|
||||
import type { SshConnectionState } from '../../shared/ssh-types'
|
||||
import type {
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearAttachResult,
|
||||
@@ -1182,6 +1184,10 @@ const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024
|
||||
const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024
|
||||
const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024
|
||||
|
||||
function isClientDisconnectedError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === 'client_disconnected'
|
||||
}
|
||||
|
||||
function createTerminalRevealWarning(handle: string, error?: unknown): string {
|
||||
const reason =
|
||||
error instanceof Error && error.message.trim().length > 0
|
||||
@@ -1414,6 +1420,22 @@ function getRuntimeFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
}
|
||||
|
||||
// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime
|
||||
// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo
|
||||
// (resolves to ssh:<id>), so it must not be adopted/matched by a runtime host at the same path.
|
||||
function runtimeRepoMatchesExecutionHost(
|
||||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): boolean {
|
||||
if (executionHostId == null) {
|
||||
return true
|
||||
}
|
||||
if (repo.executionHostId != null) {
|
||||
return repo.executionHostId === executionHostId
|
||||
}
|
||||
return repo.connectionId == null
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
@@ -2789,6 +2811,12 @@ export class OrcaRuntimeService {
|
||||
this.emitClientEvent({ type: 'reposChanged' })
|
||||
}
|
||||
|
||||
// Why: SSH state changes originate in main's ssh handlers, not in runtime
|
||||
// methods, so they need a public entry point onto the client-event stream.
|
||||
notifySshStateChanged(targetId: string, state: SshConnectionState): void {
|
||||
this.emitClientEvent({ type: 'sshStateChanged', targetId, state })
|
||||
}
|
||||
|
||||
private notifyActivateWorktree(
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
@@ -6215,6 +6243,32 @@ export class OrcaRuntimeService {
|
||||
return { ptyId: leaf.ptyId }
|
||||
}
|
||||
|
||||
// Why: remote clients hold handles across transport reconnects. A handle
|
||||
// minted for a concrete PTY must never silently adopt a different PTY that
|
||||
// later occupies the same pane — that misroutes keystrokes (#7718). Handles
|
||||
// still awaiting their first PTY (ptyId null) may adopt it, which preserves
|
||||
// the mobile pre-spawn subscribe flow.
|
||||
resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (record.tabId.startsWith('pty:')) {
|
||||
return { ptyId: record.ptyId }
|
||||
}
|
||||
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
|
||||
if (!leaf) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
record.ptyId !== null &&
|
||||
(leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration)
|
||||
) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return { ptyId: leaf.ptyId }
|
||||
}
|
||||
|
||||
async resolveTerminalCwd(handle: string): Promise<string | null> {
|
||||
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
|
||||
if (!ptyId) {
|
||||
@@ -9873,7 +9927,7 @@ export class OrcaRuntimeService {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git')
|
||||
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId)
|
||||
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
if (setup.projectId !== args.projectId) {
|
||||
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
|
||||
@@ -9912,7 +9966,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
|
||||
const repo = await this.cloneRepo(args.url, args.destination)
|
||||
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
|
||||
return await this.setupProjectExistingFolder({
|
||||
projectId: args.projectId,
|
||||
hostId: args.hostId,
|
||||
@@ -10313,7 +10367,11 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise<Repo> {
|
||||
async addRepo(
|
||||
path: string,
|
||||
kind: 'git' | 'folder' = 'git',
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
@@ -10326,8 +10384,29 @@ export class OrcaRuntimeService {
|
||||
throw new Error(`Not a valid git repository: ${path}`)
|
||||
}
|
||||
|
||||
const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path))
|
||||
const existing = this.store.getRepos().find((repo) => {
|
||||
if (!runtimePathsEqual(repo.path, path)) {
|
||||
return false
|
||||
}
|
||||
return runtimeRepoMatchesExecutionHost(repo, executionHostId)
|
||||
})
|
||||
if (existing) {
|
||||
// Only a runtime host backfills a legacy unstamped repo. An unstamped repo is
|
||||
// indistinguishable from a genuine local repo (both have null executionHostId and
|
||||
// connectionId), so we never stamp local/ssh onto it — that would re-attribute a
|
||||
// real local project to the wrong host. Runtime is the only host that lost its
|
||||
// identity to the pre-#7018 path-only import and needs the backfill.
|
||||
if (
|
||||
existing.executionHostId == null &&
|
||||
parseExecutionHostId(executionHostId)?.kind === 'runtime'
|
||||
) {
|
||||
const adopted =
|
||||
this.store.updateRepo(existing.id, { executionHostId }) ??
|
||||
({ ...existing, executionHostId } as Repo)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
return adopted
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -10337,6 +10416,7 @@ export class OrcaRuntimeService {
|
||||
path,
|
||||
displayName: getRepoName(path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(executionHostId != null ? { executionHostId } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind,
|
||||
@@ -10475,7 +10555,11 @@ export class OrcaRuntimeService {
|
||||
return { repo: this.store.getRepo(repo.id) ?? repo }
|
||||
}
|
||||
|
||||
async cloneRepo(url: string, destination: string): Promise<Repo> {
|
||||
async cloneRepo(
|
||||
url: string,
|
||||
destination: string,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
@@ -10503,7 +10587,8 @@ export class OrcaRuntimeService {
|
||||
trimmedUrl,
|
||||
trimmedDestination,
|
||||
clonePath,
|
||||
clonePathKey
|
||||
clonePathKey,
|
||||
executionHostId
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
@@ -10517,14 +10602,19 @@ export class OrcaRuntimeService {
|
||||
trimmedUrl: string,
|
||||
trimmedDestination: string,
|
||||
clonePath: string,
|
||||
clonePathKey: string
|
||||
clonePathKey: string,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existingBeforeClone = this.store
|
||||
.getRepos()
|
||||
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
|
||||
.find(
|
||||
(repo) =>
|
||||
getClonePathComparisonKey(repo.path) === clonePathKey &&
|
||||
runtimeRepoMatchesExecutionHost(repo, executionHostId)
|
||||
)
|
||||
if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) {
|
||||
return existingBeforeClone
|
||||
}
|
||||
@@ -10584,7 +10674,11 @@ export class OrcaRuntimeService {
|
||||
|
||||
const existing = this.store
|
||||
.getRepos()
|
||||
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
|
||||
.find(
|
||||
(repo) =>
|
||||
getClonePathComparisonKey(repo.path) === clonePathKey &&
|
||||
runtimeRepoMatchesExecutionHost(repo, executionHostId)
|
||||
)
|
||||
if (existing) {
|
||||
if (isFolderRepo(existing)) {
|
||||
const updated = this.store.updateRepo(existing.id, { kind: 'git' })
|
||||
@@ -10605,6 +10699,7 @@ export class OrcaRuntimeService {
|
||||
path: clonePath,
|
||||
displayName: getRepoName(clonePath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(executionHostId != null ? { executionHostId } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
@@ -16198,6 +16293,7 @@ export class OrcaRuntimeService {
|
||||
launchAgent?: TuiAgent
|
||||
activate?: boolean
|
||||
clientMutationId?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const mutationId = opts.clientMutationId
|
||||
@@ -16239,6 +16335,7 @@ export class OrcaRuntimeService {
|
||||
launchAgent?: TuiAgent
|
||||
activate?: boolean
|
||||
clientMutationId?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
this.assertGraphReady()
|
||||
@@ -16278,8 +16375,16 @@ export class OrcaRuntimeService {
|
||||
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
reject(new Error('Terminal creation timed out'))
|
||||
}, 10_000)
|
||||
// Why: a dead client connection cancels the wait; the renderer tab (and
|
||||
// its shell) stays alive for the host and mirrors on reconnect (#7718).
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
reject(new Error('client_disconnected'))
|
||||
}
|
||||
|
||||
const handler = (
|
||||
event: Electron.IpcMainEvent,
|
||||
@@ -16290,12 +16395,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
clearTimeout(timer)
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
if (r.error) {
|
||||
reject(new Error(r.error))
|
||||
} else {
|
||||
resolve({ tabId: r.tabId!, title: r.title ?? '' })
|
||||
}
|
||||
}
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
ipcMain.on('terminal:tabCreateReply', handler)
|
||||
win.webContents.send('terminal:requestTabCreate', {
|
||||
requestId,
|
||||
@@ -16333,18 +16440,25 @@ export class OrcaRuntimeService {
|
||||
// wait resolves without depending on a graph sync.
|
||||
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
|
||||
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS
|
||||
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
|
||||
signal: opts.signal
|
||||
})
|
||||
if (this.isReadyMobileTerminalSurface(surface)) {
|
||||
return surface
|
||||
}
|
||||
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
|
||||
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
|
||||
requireReady: true
|
||||
requireReady: true,
|
||||
signal: opts.signal
|
||||
}).catch(() => null)
|
||||
if (readySurface) {
|
||||
return readySurface
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
// Why: nobody is waiting for this create anymore; do not materialize
|
||||
// or roll back — the renderer's own publication settles the tab.
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
|
||||
if (!pendingSurface) {
|
||||
throw new Error('Timed out waiting for terminal surface after creation')
|
||||
@@ -16378,6 +16492,16 @@ export class OrcaRuntimeService {
|
||||
return rescued
|
||||
}
|
||||
}
|
||||
// Why: don't roll back when (a) the client connection died — the wait
|
||||
// was cancelled, not the spawn — or (b) a live shell already backs the
|
||||
// tab (its pane key may simply not be registered yet). Killing a real
|
||||
// terminal the host user can see is the "tab dies after ~10s" bug (#7718).
|
||||
if (
|
||||
isClientDisconnectedError(error) ||
|
||||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
// Why: the renderer created the tab but no live PTY backs it (true PTY
|
||||
// spawn/handle failure). Roll the half-created tab back via the renderer
|
||||
// close path so it can't linger as a ghost in mobile snapshots, then
|
||||
@@ -16597,33 +16721,44 @@ export class OrcaRuntimeService {
|
||||
private waitForMobileTerminalSurface(
|
||||
worktreeId: string,
|
||||
parentTabId: string,
|
||||
options: { timeoutMs?: number; requireReady?: boolean } = {}
|
||||
options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS
|
||||
const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options)
|
||||
if (existing) {
|
||||
return Promise.resolve(existing)
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(new Error('client_disconnected'))
|
||||
}
|
||||
|
||||
return new Promise<RuntimeMobileSessionCreateTerminalResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
options.signal?.removeEventListener('abort', onAbort)
|
||||
const idx = this.graphSyncCallbacks.indexOf(check)
|
||||
if (idx !== -1) {
|
||||
this.graphSyncCallbacks.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('Timed out waiting for terminal surface after creation'))
|
||||
}, timeoutMs)
|
||||
// Why: a dead client connection cancels the wait immediately instead of
|
||||
// running down the timeout and triggering rollback (#7718).
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(new Error('client_disconnected'))
|
||||
}
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const check = (): void => {
|
||||
const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
const idx = this.graphSyncCallbacks.indexOf(check)
|
||||
if (idx !== -1) {
|
||||
this.graphSyncCallbacks.splice(idx, 1)
|
||||
}
|
||||
cleanup()
|
||||
resolve(next)
|
||||
}
|
||||
this.graphSyncCallbacks.push(check)
|
||||
@@ -16713,6 +16848,18 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: rollback guard, looser than findLiveRegisteredPtyForRendererTab — a
|
||||
// shell whose pane key hasn't registered yet can't be surface-rescued, but
|
||||
// it is still a real terminal the create timeout must not kill (#7718).
|
||||
private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean {
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private isReadyMobileTerminalSurface(
|
||||
surface: RuntimeMobileSessionCreateTerminalResult | null
|
||||
): boolean {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_METHODS } from './ai-vault'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue({
|
||||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: '2026-07-04T00:00:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVault.listSessions runtime RPC', () => {
|
||||
it('scans server-local sessions under the caller supplied runtime host id', async () => {
|
||||
const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions')
|
||||
expect(method).toBeDefined()
|
||||
|
||||
const params = method!.params!.parse({
|
||||
limit: 25,
|
||||
scopePaths: ['/srv/app'],
|
||||
executionHostId: 'runtime:remote-server'
|
||||
})
|
||||
|
||||
await method!.handler(params, {} as never)
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({
|
||||
limit: 25,
|
||||
scopePaths: ['/srv/app'],
|
||||
executionHostId: 'runtime:remote-server'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-runtime execution host ids before dispatch', () => {
|
||||
const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions')
|
||||
expect(method).toBeDefined()
|
||||
|
||||
expect(() =>
|
||||
method!.params!.parse({
|
||||
executionHostId: 'not-a-runtime-host'
|
||||
})
|
||||
).toThrow('Invalid runtime execution host id')
|
||||
expect(() =>
|
||||
method!.params!.parse({
|
||||
executionHostId: 'local'
|
||||
})
|
||||
).toThrow('Invalid runtime execution host id')
|
||||
expect(() =>
|
||||
method!.params!.parse({
|
||||
executionHostId: 'ssh:dev-box'
|
||||
})
|
||||
).toThrow('Invalid runtime execution host id')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { scanAiVaultSessions } from '../../../ai-vault/session-scanner'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => {
|
||||
const parsed = parseExecutionHostId(value)
|
||||
if (parsed?.kind === 'runtime') {
|
||||
return parsed.id
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid runtime execution host id'
|
||||
})
|
||||
return z.NEVER
|
||||
})
|
||||
|
||||
const listSessionsParamsSchema = z.object({
|
||||
limit: z.number().int().positive().optional(),
|
||||
force: z.boolean().optional(),
|
||||
scopePaths: z.array(z.string()).optional(),
|
||||
executionHostId: executionHostIdSchema
|
||||
})
|
||||
|
||||
export const AI_VAULT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'aiVault.listSessions',
|
||||
params: listSessionsParamsSchema,
|
||||
handler: async (params) =>
|
||||
scanAiVaultSessions({
|
||||
limit: params.limit,
|
||||
scopePaths: params.scopePaths,
|
||||
executionHostId: params.executionHostId
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RpcAnyMethod } from '../core'
|
||||
import { STATUS_METHODS } from './status'
|
||||
import { AI_VAULT_METHODS } from './ai-vault'
|
||||
import { AUTOMATION_METHODS } from './automations'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
@@ -39,6 +40,7 @@ import { EMULATOR_METHODS } from './emulator'
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...STATUS_METHODS,
|
||||
...AI_VAULT_METHODS,
|
||||
...AUTOMATION_METHODS,
|
||||
...REPO_METHODS,
|
||||
...WORKTREE_METHODS,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'session.tabs.createTerminal',
|
||||
params: CreateTerminalTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
handler: async (params, { runtime, signal }) =>
|
||||
runtime.createMobileSessionTerminal(params.worktree, {
|
||||
afterTabId: params.afterTabId,
|
||||
targetGroupId: params.targetGroupId,
|
||||
@@ -54,7 +54,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
activate: params.activate,
|
||||
clientMutationId: params.clientMutationId
|
||||
clientMutationId: params.clientMutationId,
|
||||
// Why: a dead client connection must cancel the surface wait instead
|
||||
// of running down the timeout and rolling back a live tab (#7718).
|
||||
signal
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -4,14 +4,23 @@ import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SSH_METHODS } from './ssh'
|
||||
|
||||
const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
connectRegisteredSshTargetMock,
|
||||
getRegisteredSshStateMock,
|
||||
listRegisteredSshTargetsMock,
|
||||
listRegisteredRemovedSshTargetLabelsMock
|
||||
} = vi.hoisted(() => ({
|
||||
connectRegisteredSshTargetMock: vi.fn(),
|
||||
getRegisteredSshStateMock: vi.fn()
|
||||
getRegisteredSshStateMock: vi.fn(),
|
||||
listRegisteredSshTargetsMock: vi.fn(),
|
||||
listRegisteredRemovedSshTargetLabelsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ipc/ssh', () => ({
|
||||
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
|
||||
getRegisteredSshState: getRegisteredSshStateMock
|
||||
getRegisteredSshState: getRegisteredSshStateMock,
|
||||
listRegisteredSshTargets: listRegisteredSshTargetsMock,
|
||||
listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock
|
||||
}))
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
@@ -62,4 +71,26 @@ describe('ssh RPC methods', () => {
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { state: null } })
|
||||
})
|
||||
|
||||
it('lists the registered SSH targets for paired clients', async () => {
|
||||
const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }]
|
||||
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listTargets'))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { targets } })
|
||||
})
|
||||
|
||||
it('lists removed-target labels for ghost-host display on paired clients', async () => {
|
||||
const labels = { 'ssh-old': 'Dev box' }
|
||||
listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels'))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { labels } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh'
|
||||
import {
|
||||
connectRegisteredSshTarget,
|
||||
getRegisteredSshState,
|
||||
listRegisteredRemovedSshTargetLabels,
|
||||
listRegisteredSshTargets
|
||||
} from '../../../ipc/ssh'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const SshTarget = z.object({
|
||||
@@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [
|
||||
name: 'ssh.connect',
|
||||
params: SshTarget,
|
||||
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.listTargets',
|
||||
params: null,
|
||||
handler: () => ({ targets: listRegisteredSshTargets() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.listRemovedTargetLabels',
|
||||
params: null,
|
||||
handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() })
|
||||
})
|
||||
]
|
||||
|
||||
@@ -904,7 +904,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
params: TerminalSend,
|
||||
handler: async (params, { runtime }) => {
|
||||
await assertTerminalSendTextWithinLimit(params.text)
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
// Why: guarded resolution — a stale handle must fail with
|
||||
// terminal_handle_stale (clients recover by re-deriving the handle)
|
||||
// instead of evaluating driver/lock state against the wrong PTY (#7718).
|
||||
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
|
||||
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
|
||||
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
|
||||
return {
|
||||
@@ -1436,8 +1439,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const request = parsed.data
|
||||
detachStream(request.streamId, false)
|
||||
|
||||
let leaf = runtime.resolveLeafForHandle(request.terminal)
|
||||
const isMobile = request.client?.type === 'mobile'
|
||||
let leaf: { ptyId: string | null } | null
|
||||
try {
|
||||
// Why: guarded resolution — binding the output stream to whatever
|
||||
// PTY now occupies a stale handle's pane silently mirrors the wrong
|
||||
// terminal after a reconnect (#7718). terminal_handle_stale lets the
|
||||
// client re-derive the handle from the current session snapshot.
|
||||
leaf = runtime.resolveLiveLeafForHandle(request.terminal)
|
||||
} catch {
|
||||
sendStreamError(request.streamId, 'terminal_handle_stale')
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
if (!leaf?.ptyId && isMobile) {
|
||||
try {
|
||||
const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal)
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'user@host:~$ ',
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('terminal multiplex RPC', () => {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'snapshot',
|
||||
@@ -258,7 +258,7 @@ describe('terminal multiplex RPC', () => {
|
||||
| undefined
|
||||
const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
@@ -371,7 +371,7 @@ describe('terminal multiplex RPC', () => {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'snapshot',
|
||||
@@ -477,7 +477,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }),
|
||||
@@ -573,7 +573,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
@@ -681,7 +681,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
@@ -781,7 +781,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
@@ -1305,7 +1305,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
|
||||
waitForLeafPtyId: vi.fn(
|
||||
(_handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
|
||||
new Promise<string>((_resolve, reject) => {
|
||||
@@ -1372,6 +1372,78 @@ describe('terminal multiplex RPC', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => {
|
||||
// Why: after a reconnect a client can resubscribe with a handle whose
|
||||
// pane now hosts a different PTY. Binding the stream anyway would mirror
|
||||
// (and type into) the wrong terminal (#7718); the client recovers from
|
||||
// terminal_handle_stale by re-deriving the handle from the next snapshot.
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(() => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
})
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-stale-handle',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 9,
|
||||
terminal: 'stale-terminal',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages
|
||||
.map((msg) => JSON.parse(msg).result)
|
||||
.some((result) => result?.type === 'end' && result.streamId === 9)
|
||||
).toBe(true)
|
||||
)
|
||||
const errorFrame = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.Error)
|
||||
expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('terminal_handle_stale')
|
||||
// The stream must never have bound to any PTY.
|
||||
expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled()
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-stale-handle')?.()
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('bounds live output queued while a multiplex snapshot is loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -1385,7 +1457,7 @@ describe('terminal multiplex RPC', () => {
|
||||
const dataListenerRef: { current?: (data: string) => void } = {}
|
||||
const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
@@ -1494,7 +1566,7 @@ describe('terminal multiplex RPC', () => {
|
||||
const dataListenerRef: { current?: (data: string) => void } = {}
|
||||
const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
@@ -1607,7 +1679,7 @@ describe('terminal multiplex RPC', () => {
|
||||
rows: number
|
||||
}) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
|
||||
@@ -75,9 +75,34 @@ describe('terminal send RPC', () => {
|
||||
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('fails terminal.send with terminal_handle_stale for a stale handle instead of probing the wrong PTY', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(() => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}),
|
||||
sendTerminal: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'stale-terminal',
|
||||
text: 'x',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) {
|
||||
throw new Error('expected stale handle error')
|
||||
}
|
||||
expect(response.error.message).toContain('terminal_handle_stale')
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops desktop input while a mobile client owns the terminal floor', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }),
|
||||
sendTerminal: vi.fn(),
|
||||
mobileTookFloor: vi.fn()
|
||||
@@ -109,7 +134,7 @@ describe('terminal send RPC', () => {
|
||||
|
||||
it('accepts legacy clientless mobile input when the current driver is mobile', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }),
|
||||
sendTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
@@ -198,7 +223,7 @@ describe('terminal send RPC', () => {
|
||||
vi.useFakeTimers()
|
||||
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
sendTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
@@ -236,7 +261,7 @@ describe('terminal send RPC', () => {
|
||||
|
||||
it('refuses guarded terminal sends when the agent needs permission', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
getTerminalAgentStatus: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
@@ -274,7 +299,7 @@ describe('terminal send RPC', () => {
|
||||
|
||||
it('allows guarded terminal sends when the agent is sendable', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
getTerminalAgentStatus: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
@@ -316,7 +341,7 @@ describe('terminal send RPC', () => {
|
||||
|
||||
it('refuses guarded combined text and submit sends before any PTY write', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
getTerminalAgentStatus: vi.fn(),
|
||||
sendTerminal: vi.fn()
|
||||
@@ -350,7 +375,7 @@ describe('terminal send RPC', () => {
|
||||
|
||||
it('rechecks the mobile floor lock immediately before guarded PTY writes', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ kind: 'desktop' })
|
||||
|
||||
@@ -308,6 +308,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'settings.update',
|
||||
'ssh.connect',
|
||||
'ssh.getState',
|
||||
'ssh.listRemovedTargetLabels',
|
||||
'ssh.listTargets',
|
||||
'speech.dictation.cancel',
|
||||
'speech.dictation.chunk',
|
||||
'speech.dictation.finish',
|
||||
|
||||
@@ -307,6 +307,81 @@ describe('SshChannelMultiplexer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('wake guard (timer pause across system sleep, #7773)', () => {
|
||||
it('does not kill a healthy link on the first tick after a long timer pause', () => {
|
||||
// Reach steady state with pending unacked keepalives (<5s old at pause).
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
|
||||
// Simulate sleep/App Nap: wall clock jumps far ahead with no ticks.
|
||||
// Without the guard, the first post-wake tick sees lastReceivedAt and
|
||||
// the pre-pause keepalive both >20s stale and disposes the mux.
|
||||
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
|
||||
const writesBefore = transport.written.length
|
||||
vi.advanceTimersByTime(5_000)
|
||||
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
// The guard probes immediately with a fresh keepalive.
|
||||
expect(transport.written.length).toBeGreaterThan(writesBefore)
|
||||
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
|
||||
})
|
||||
|
||||
it('keeps the link alive after wake when frames resume', () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
|
||||
vi.advanceTimersByTime(5_000) // guard tick
|
||||
|
||||
// The relay answers the post-wake probe; the link must stay up.
|
||||
let seq = 1
|
||||
for (let i = 0; i < 8; i++) {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
transport.dataCallbacks[0](encodeKeepAliveFrame(seq++, 0))
|
||||
}
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
})
|
||||
|
||||
it('still detects a genuinely dead link within the next window after wake', () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
|
||||
vi.advanceTimersByTime(5_000) // guard tick: reset + probe, no kill
|
||||
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
// No frames arrive after the guard reset; the honest window expires.
|
||||
vi.advanceTimersByTime(25_000)
|
||||
expect(mux.isDisposed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeLiveness', () => {
|
||||
it('sends a keepalive and resolves true when any frame arrives', async () => {
|
||||
const writesBefore = transport.written.length
|
||||
const probe = mux.probeLiveness(5_000)
|
||||
|
||||
expect(transport.written.length).toBe(writesBefore + 1)
|
||||
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
|
||||
|
||||
transport.dataCallbacks[0](encodeKeepAliveFrame(1, 0))
|
||||
await expect(probe).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false when no frame arrives before the timeout', async () => {
|
||||
const probe = mux.probeLiveness(5_000)
|
||||
vi.advanceTimersByTime(5_000)
|
||||
await expect(probe).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves false when the mux is disposed while probing', async () => {
|
||||
const probe = mux.probeLiveness(5_000)
|
||||
mux.dispose()
|
||||
await expect(probe).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves false immediately on a disposed mux', async () => {
|
||||
mux.dispose()
|
||||
await expect(mux.probeLiveness(5_000)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('rejects all pending requests on dispose', async () => {
|
||||
const promise = mux.request('pty.spawn')
|
||||
|
||||
@@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record<string, unknown>) => voi
|
||||
export type RequestHandler = (params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
// Why: a tick gap far beyond the interval means the process was paused
|
||||
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
|
||||
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
|
||||
|
||||
export class SshChannelMultiplexer {
|
||||
private decoder: FrameDecoder
|
||||
@@ -58,6 +61,10 @@ export class SshChannelMultiplexer {
|
||||
// Track the oldest unacked outgoing message timestamp
|
||||
private unackedTimestamps = new Map<number, number>()
|
||||
|
||||
// Why: liveness probes (#7773) resolve on the first frame of any kind —
|
||||
// a keepalive ack proves the relay round-trip without a full RPC.
|
||||
private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = []
|
||||
|
||||
constructor(transport: MultiplexerTransport) {
|
||||
this.transport = transport
|
||||
|
||||
@@ -232,6 +239,31 @@ export class SshChannelMultiplexer {
|
||||
this.sendMessage(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a fresh keepalive and resolve true when any frame arrives before the
|
||||
* timeout. Used on system resume to distinguish a link that survived sleep
|
||||
* from a dead one before tearing the session down (#7773).
|
||||
*/
|
||||
probeLiveness(timeoutMs: number): Promise<boolean> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const settle = (alive: boolean): void => {
|
||||
clearTimeout(timer)
|
||||
const idx = this.livenessProbeWaiters.indexOf(waiter)
|
||||
if (idx !== -1) {
|
||||
this.livenessProbeWaiters.splice(idx, 1)
|
||||
}
|
||||
resolve(alive)
|
||||
}
|
||||
const waiter = { succeed: () => settle(true), fail: () => settle(false) }
|
||||
const timer = setTimeout(() => settle(false), timeoutMs)
|
||||
this.livenessProbeWaiters.push(waiter)
|
||||
this.sendKeepAlive()
|
||||
})
|
||||
}
|
||||
|
||||
dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -259,6 +291,10 @@ export class SshChannelMultiplexer {
|
||||
reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed'
|
||||
const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
|
||||
|
||||
for (const waiter of this.livenessProbeWaiters.splice(0)) {
|
||||
waiter.fail()
|
||||
}
|
||||
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
pending.cleanup()
|
||||
const err = new Error(errorMessage) as Error & { code: string }
|
||||
@@ -322,6 +358,12 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
|
||||
private handleFrame(frame: DecodedFrame): void {
|
||||
// Why: any decoded frame proves the relay round-trip is alive; resolve
|
||||
// pending resume probes before ordinary dispatch (#7773).
|
||||
for (const waiter of this.livenessProbeWaiters.splice(0)) {
|
||||
waiter.succeed()
|
||||
}
|
||||
|
||||
// Update ack tracking
|
||||
if (frame.id > this.highestReceivedSeq) {
|
||||
this.highestReceivedSeq = frame.id
|
||||
@@ -453,12 +495,28 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
|
||||
private startTimeoutCheck(): void {
|
||||
let lastTickAt = Date.now()
|
||||
this.timeoutTimer = setInterval(() => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const sinceLastTick = now - lastTickAt
|
||||
lastTickAt = now
|
||||
// Why: after sleep/App Nap the pre-pause keepalive looks stale on the
|
||||
// first post-wake tick, killing a healthy link (#7773). Reset staleness
|
||||
// tracking, probe with a fresh keepalive, and let the NEXT full window
|
||||
// make an honest liveness determination.
|
||||
if (sinceLastTick > WAKE_GAP_MS) {
|
||||
this.lastReceivedAt = now
|
||||
for (const seq of this.unackedTimestamps.keys()) {
|
||||
this.unackedTimestamps.set(seq, now)
|
||||
}
|
||||
this.sendKeepAlive()
|
||||
return
|
||||
}
|
||||
|
||||
const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS
|
||||
|
||||
// Check oldest unacked message
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PortScanner,
|
||||
SSH_PORT_SCAN_BASE_INTERVAL_MS,
|
||||
SSH_PORT_SCAN_MAX_INTERVAL_MS,
|
||||
type PortScannerWindowVisibility
|
||||
} from './ssh-port-scanner'
|
||||
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
||||
import type { DetectedPort } from '../../shared/ssh-types'
|
||||
|
||||
type VisibilityHarness = {
|
||||
visibility: PortScannerWindowVisibility
|
||||
setVisible: (visible: boolean) => void
|
||||
listenerCount: () => number
|
||||
}
|
||||
|
||||
function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness {
|
||||
let visible = initiallyVisible
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
visibility: {
|
||||
isWindowVisible: () => visible,
|
||||
onWindowBecameVisible: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
setVisible: (next) => {
|
||||
const wasVisible = visible
|
||||
visible = next
|
||||
if (!wasVisible && next) {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
},
|
||||
listenerCount: () => listeners.size
|
||||
}
|
||||
}
|
||||
|
||||
function port(portNumber: number): DetectedPort {
|
||||
return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' }
|
||||
}
|
||||
|
||||
function createMux(ports: () => DetectedPort[]): {
|
||||
mux: SshChannelMultiplexer
|
||||
request: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' }))
|
||||
return { mux: { request } as unknown as SshChannelMultiplexer, request }
|
||||
}
|
||||
|
||||
const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS
|
||||
const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS
|
||||
|
||||
describe('PortScanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => {
|
||||
const harness = createVisibilityHarness(false)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('polls at the base cadence while visible when results keep changing', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let next = 3000
|
||||
const { mux, request } = createMux(() => [port(next++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 5 simulated base intervals -> 5 more scans (no backoff while changing).
|
||||
await vi.advanceTimersByTimeAsync(5 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(6)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let ports = [port(3000)]
|
||||
const { mux, request } = createMux(() => ports)
|
||||
const onChanged = vi.fn()
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
|
||||
// t=0: first scan is a change (empty -> {3000}) and stays at base.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
// t=12s: unchanged -> next wait doubles to 24s.
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
// t=24s: nothing (waiting until t=36s).
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
// t=36s: unchanged -> next wait caps at 30s (not 48s).
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// t=65.999s: still waiting.
|
||||
await vi.advanceTimersByTimeAsync(MAX - 1)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// t=66s: unchanged -> next wait remains capped at 30s.
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MAX - 1)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(request).toHaveBeenCalledTimes(5)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A changed result resets the cadence back to base.
|
||||
ports = [port(3000), port(4000)]
|
||||
await vi.advanceTimersByTimeAsync(MAX)
|
||||
expect(request).toHaveBeenCalledTimes(6)
|
||||
expect(onChanged).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(7)
|
||||
expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000])
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
const onChanged = vi.fn()
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
|
||||
// Back off: scans at t=0, t=12s, t=36s -> interval now 48s.
|
||||
await vi.advanceTimersByTimeAsync(3 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// Reconnect paths call startScanning again: immediate scan, base cadence.
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(5)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let next = 3000
|
||||
const { mux, request } = createMux(() => [port(next++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null
|
||||
const request = vi.fn(
|
||||
() =>
|
||||
new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn())
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The chain waits for the in-flight request instead of stacking more.
|
||||
await vi.advanceTimersByTimeAsync(10 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst!({ ports: [port(3000)], platform: 'linux' })
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('stopScanning halts polling and detaches the visibility listener', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(harness.listenerCount()).toBe(1)
|
||||
|
||||
scanner.stopScanning('t1')
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
harness.setVisible(false)
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(scanner.getDetectedPorts('t1')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps targets independent: stopping one host leaves the other scanning', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let nextA = 3000
|
||||
let nextB = 4000
|
||||
const a = createMux(() => [port(nextA++)])
|
||||
const b = createMux(() => [port(nextB++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('host-a', a.mux, vi.fn())
|
||||
scanner.startScanning('host-b', b.mux, vi.fn())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(a.request).toHaveBeenCalledTimes(1)
|
||||
expect(b.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
scanner.stopScanning('host-a')
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(a.request).toHaveBeenCalledTimes(1)
|
||||
expect(b.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
scanner.dispose()
|
||||
await vi.advanceTimersByTimeAsync(10 * BASE)
|
||||
expect(b.request).toHaveBeenCalledTimes(2)
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
||||
import type { DetectedPort } from '../../shared/ssh-types'
|
||||
|
||||
const POLL_INTERVAL_MS = 3_000
|
||||
// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote
|
||||
// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the
|
||||
// old 3s while keeping new-port detection well under the 30s workspace-scanner
|
||||
// cadence users already accept.
|
||||
export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000
|
||||
// Why: idle backoff cap — an unchanged port set doubles the interval up to
|
||||
// this bound, so a quiet remote costs two scans per minute instead of twenty
|
||||
// without exceeding the existing workspace-port scanner's visible cadence.
|
||||
export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000
|
||||
|
||||
export type PortScannerWindowVisibility = {
|
||||
isWindowVisible: () => boolean
|
||||
// Fires when a hidden/minimized window is shown again; returns unsubscribe.
|
||||
onWindowBecameVisible: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
type ScanHandle = {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
intervalMs: number
|
||||
// Why: while the window is hidden the scan chain is parked outright — no
|
||||
// timer wakeups, no remote requests — and the visibility listener resumes
|
||||
// it with an immediate scan so ports opened while hidden surface at once.
|
||||
parkedWhileHidden: boolean
|
||||
unsubscribeVisibility: () => void
|
||||
// Why: keyed by "host:port" (not just port) so that host-distinct listeners
|
||||
// on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately.
|
||||
previousPorts: Map<string, DetectedPort>
|
||||
@@ -17,6 +37,8 @@ type ScanHandle = {
|
||||
export class PortScanner {
|
||||
private handles = new Map<string, ScanHandle>()
|
||||
|
||||
constructor(private visibility: PortScannerWindowVisibility) {}
|
||||
|
||||
startScanning(
|
||||
targetId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
@@ -25,14 +47,18 @@ export class PortScanner {
|
||||
this.stopScanning(targetId)
|
||||
|
||||
const handle: ScanHandle = {
|
||||
timer: null!,
|
||||
timer: null,
|
||||
intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS,
|
||||
parkedWhileHidden: false,
|
||||
unsubscribeVisibility: () => {},
|
||||
previousPorts: new Map(),
|
||||
initialPorts: null
|
||||
}
|
||||
const isCurrent = (): boolean => this.handles.get(targetId) === handle
|
||||
|
||||
// Why: guard against overlapping scans. On slow remotes, /proc/*/fd walks
|
||||
// can take longer than POLL_INTERVAL_MS. Without this guard, setInterval
|
||||
// would stack up concurrent requests on the shared SSH multiplexer.
|
||||
// Why: guard against overlapping scans. The timer chain only reschedules
|
||||
// after a poll completes, but the visibility-resume path can race a poll
|
||||
// still in flight on a slow remote.
|
||||
let polling = false
|
||||
const poll = async (): Promise<void> => {
|
||||
if (polling) {
|
||||
@@ -45,7 +71,7 @@ export class PortScanner {
|
||||
platform: string
|
||||
}
|
||||
|
||||
if (!this.handles.has(targetId)) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,7 +86,10 @@ export class PortScanner {
|
||||
|
||||
if (!portsEqual(handle.previousPorts, currentPorts)) {
|
||||
handle.previousPorts = currentPorts
|
||||
handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS
|
||||
onChanged(targetId, result.ports, result.platform)
|
||||
} else {
|
||||
handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS)
|
||||
}
|
||||
} catch {
|
||||
// Relay disconnected or request timed out — retry on next interval
|
||||
@@ -69,10 +98,32 @@ export class PortScanner {
|
||||
}
|
||||
}
|
||||
|
||||
handle.timer = setInterval(() => void poll(), POLL_INTERVAL_MS)
|
||||
this.handles.set(targetId, handle)
|
||||
const tick = async (): Promise<void> => {
|
||||
handle.timer = null
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!this.visibility.isWindowVisible()) {
|
||||
handle.parkedWhileHidden = true
|
||||
return
|
||||
}
|
||||
await poll()
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
handle.timer = setTimeout(() => void tick(), handle.intervalMs)
|
||||
}
|
||||
|
||||
void poll()
|
||||
handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => {
|
||||
if (!isCurrent() || !handle.parkedWhileHidden) {
|
||||
return
|
||||
}
|
||||
handle.parkedWhileHidden = false
|
||||
void tick()
|
||||
})
|
||||
|
||||
this.handles.set(targetId, handle)
|
||||
void tick()
|
||||
}
|
||||
|
||||
getDetectedPorts(targetId: string): DetectedPort[] {
|
||||
@@ -88,7 +139,10 @@ export class PortScanner {
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
clearInterval(handle.timer)
|
||||
if (handle.timer) {
|
||||
clearTimeout(handle.timer)
|
||||
}
|
||||
handle.unsubscribeVisibility()
|
||||
this.handles.delete(targetId)
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ function createMockDeps() {
|
||||
} as unknown as SshPortForwardManager
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
// Why: the port scanner visibility-gates its ticks; a visible mock window
|
||||
// keeps establish-path tests exercising the scan-on-ready behavior.
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
const getMainWindow = vi.fn().mockReturnValue(mockWindow)
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
|
||||
import { PortScanner } from './ssh-port-scanner'
|
||||
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
|
||||
import type { SshPortForwardManager } from './ssh-port-forward'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
@@ -982,7 +983,13 @@ export class SshRelaySession {
|
||||
if (!this.mux || this.isDisposed()) {
|
||||
return
|
||||
}
|
||||
const scanner = new PortScanner()
|
||||
// Why: each scan walks /proc/*/fd on the remote host, so the scanner skips
|
||||
// ticks entirely while no window can show the results (hidden to tray or
|
||||
// minimized overnight) and rescans immediately when the window returns.
|
||||
const scanner = new PortScanner({
|
||||
isWindowVisible: () => isMainWindowVisible(this.getMainWindow()),
|
||||
onWindowBecameVisible: onMainWindowBecameVisible
|
||||
})
|
||||
this.portScanner = scanner
|
||||
// Why: capture the scanner instance so that a late ports.detect callback
|
||||
// from a previous relay session (before reconnect replaced it) is silently
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types'
|
||||
import {
|
||||
migrateUiHostScopeSshTargetId,
|
||||
migrateWorkspaceSessionSshTargetId
|
||||
} from './ssh-target-id-migration'
|
||||
|
||||
const OLD_ID = 'ssh-1783337351840-ohabf0'
|
||||
const NEW_ID = 'ssh-1783400000000-fresh1'
|
||||
|
||||
const makeTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({
|
||||
id: 'tab1',
|
||||
ptyId: null,
|
||||
worktreeId: 'r1::/wt',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeSession = (overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState => ({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('migrateWorkspaceSessionSshTargetId', () => {
|
||||
it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => {
|
||||
const session = makeSession({
|
||||
tabsByWorktree: {
|
||||
'r1::/wt': [
|
||||
makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }),
|
||||
makeTab({ id: 'tab2', ptyId: 'local-pty-7' }),
|
||||
makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` })
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` }
|
||||
}
|
||||
},
|
||||
remoteSessionIdsByTabId: {
|
||||
tab1: `ssh:${OLD_ID}@@pty-3`,
|
||||
tab3: `ssh:other-target@@pty-1`
|
||||
}
|
||||
})
|
||||
|
||||
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
|
||||
|
||||
const tabs = session.tabsByWorktree['r1::/wt']
|
||||
expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`)
|
||||
// Local and other-target pty ids must be untouched.
|
||||
expect(tabs[1].ptyId).toBe('local-pty-7')
|
||||
expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`)
|
||||
expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
|
||||
'leaf-1': `ssh:${NEW_ID}@@pty-3`
|
||||
})
|
||||
expect(session.remoteSessionIdsByTabId).toEqual({
|
||||
tab1: `ssh:${NEW_ID}@@pty-3`,
|
||||
tab3: `ssh:other-target@@pty-1`
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => {
|
||||
const session = makeSession({
|
||||
activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated']
|
||||
})
|
||||
|
||||
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
|
||||
expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated'])
|
||||
})
|
||||
|
||||
it('re-points sleeping agent records pinned to the old connection', () => {
|
||||
const session = makeSession({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab1:leaf-1': {
|
||||
paneKey: 'tab1:leaf-1',
|
||||
worktreeId: 'r1::/wt',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 's-1' },
|
||||
prompt: 'p',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
connectionId: OLD_ID
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
|
||||
expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID)
|
||||
})
|
||||
|
||||
it('returns false when nothing references the old id', () => {
|
||||
const session = makeSession({
|
||||
tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] },
|
||||
activeConnectionIdsAtShutdown: ['ssh-unrelated']
|
||||
})
|
||||
|
||||
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false)
|
||||
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateUiHostScopeSshTargetId', () => {
|
||||
const makeUi = (overrides: Partial<PersistedUIState>): PersistedUIState =>
|
||||
({ ...overrides }) as PersistedUIState
|
||||
|
||||
it('re-points scope, visible hosts, and host order, deduping collisions', () => {
|
||||
const ui = makeUi({
|
||||
workspaceHostScope: `ssh:${OLD_ID}`,
|
||||
visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`],
|
||||
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local']
|
||||
})
|
||||
|
||||
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
|
||||
expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`)
|
||||
expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`])
|
||||
expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local'])
|
||||
})
|
||||
|
||||
it('returns false when the old host id appears nowhere', () => {
|
||||
const ui = makeUi({
|
||||
workspaceHostScope: 'all',
|
||||
visibleWorkspaceHostIds: ['local'],
|
||||
workspaceHostOrder: ['local']
|
||||
})
|
||||
|
||||
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
|
||||
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
/**
|
||||
* Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts).
|
||||
*
|
||||
* reassignSshTargetId re-points repos/worktree metas, but the removed target's
|
||||
* id is also embedded in other persisted state: app-scoped SSH pty ids
|
||||
* ("ssh:<targetId>@@pty-N") inside the workspace session, the startup
|
||||
* reconnect list, sleeping-agent resume records, and the sidebar host-scope
|
||||
* arrays. Any survivor resurfaces later as `SSH target "<old>" not found` at
|
||||
* connect/reattach time (STA-1468), so every carrier must migrate together.
|
||||
*
|
||||
* All helpers mutate in place (matching how the Store edits this.state) and
|
||||
* return whether anything changed so callers can gate scheduleSave.
|
||||
*/
|
||||
|
||||
function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null {
|
||||
const parsed = parseAppSshPtyId(ptyId)
|
||||
if (!parsed || parsed.connectionId !== oldTargetId) {
|
||||
return null
|
||||
}
|
||||
return toAppSshPtyId(newTargetId, parsed.relayPtyId)
|
||||
}
|
||||
|
||||
function rewriteSshPtyIdRecordValues(
|
||||
record: Record<string, string> | undefined,
|
||||
oldTargetId: string,
|
||||
newTargetId: string
|
||||
): boolean {
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
for (const [key, ptyId] of Object.entries(record)) {
|
||||
const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId)
|
||||
if (next) {
|
||||
record[key] = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Re-point every old-target-id carrier inside one workspace session partition. */
|
||||
export function migrateWorkspaceSessionSshTargetId(
|
||||
session: WorkspaceSessionState,
|
||||
oldTargetId: string,
|
||||
newTargetId: string
|
||||
): boolean {
|
||||
let changed = false
|
||||
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (!tab.ptyId) {
|
||||
continue
|
||||
}
|
||||
const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId)
|
||||
if (next) {
|
||||
tab.ptyId = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) {
|
||||
if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) {
|
||||
changed = true
|
||||
}
|
||||
if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) {
|
||||
session.activeConnectionIdsAtShutdown = [
|
||||
...new Set(
|
||||
session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id))
|
||||
)
|
||||
]
|
||||
changed = true
|
||||
}
|
||||
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
|
||||
if (record.connectionId === oldTargetId) {
|
||||
record.connectionId = newTargetId
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */
|
||||
export function migrateUiHostScopeSshTargetId(
|
||||
ui: PersistedUIState,
|
||||
oldTargetId: string,
|
||||
newTargetId: string
|
||||
): boolean {
|
||||
const oldHostId = toSshExecutionHostId(oldTargetId)
|
||||
const newHostId = toSshExecutionHostId(newTargetId)
|
||||
let changed = false
|
||||
if (ui.workspaceHostScope === oldHostId) {
|
||||
ui.workspaceHostScope = newHostId
|
||||
changed = true
|
||||
}
|
||||
if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) {
|
||||
ui.visibleWorkspaceHostIds = [
|
||||
...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id)))
|
||||
]
|
||||
changed = true
|
||||
}
|
||||
if (ui.workspaceHostOrder?.includes(oldHostId)) {
|
||||
ui.workspaceHostOrder = [
|
||||
...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id)))
|
||||
]
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isMainWindowVisible } from './main-window-visibility'
|
||||
|
||||
describe('main window visibility helpers', () => {
|
||||
it('treats a minimal alive window double as visible', () => {
|
||||
expect(isMainWindowVisible({ isDestroyed: () => false })).toBe(true)
|
||||
})
|
||||
|
||||
it('parks work when the real window is hidden, minimized, destroyed, or missing', () => {
|
||||
expect(
|
||||
isMainWindowVisible({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
isMinimized: () => false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isMainWindowVisible({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(isMainWindowVisible({ isDestroyed: () => true })).toBe(false)
|
||||
expect(isMainWindowVisible(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// Why: BrowserWindow instances are recreated on macOS dock re-activation, so
|
||||
// long-lived main-process services (e.g. the SSH port scanner) subscribe to
|
||||
// this process-global signal instead of a specific window instance; index.ts
|
||||
// re-wires each new window's show/restore events into notifyMainWindowBecameVisible.
|
||||
type MainWindowBecameVisibleListener = () => void
|
||||
|
||||
type MainWindowVisibilityState = {
|
||||
isDestroyed: () => boolean
|
||||
isVisible?: () => boolean
|
||||
isMinimized?: () => boolean
|
||||
}
|
||||
|
||||
const listeners = new Set<MainWindowBecameVisibleListener>()
|
||||
|
||||
export function notifyMainWindowBecameVisible(): void {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function onMainWindowBecameVisible(listener: MainWindowBecameVisibleListener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function isMainWindowVisible(window: MainWindowVisibilityState | null): boolean {
|
||||
if (window === null || window.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: production BrowserWindow exposes both APIs, but older main-process
|
||||
// tests use minimal window doubles that should stay visible by default.
|
||||
const isVisible = window.isVisible?.() ?? true
|
||||
const isMinimized = window.isMinimized?.() ?? false
|
||||
return isVisible && !isMinimized
|
||||
}
|
||||
@@ -2247,7 +2247,9 @@ export type PreloadApi = {
|
||||
rootPath: string
|
||||
connectionId?: string
|
||||
excludePaths?: string[]
|
||||
requestToken?: string
|
||||
}) => Promise<string[]>
|
||||
cancelListFiles: (args: { requestToken: string }) => Promise<void>
|
||||
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
|
||||
importExternalPaths: (args: {
|
||||
sourcePaths: string[]
|
||||
|
||||
@@ -2648,7 +2648,10 @@ const api = {
|
||||
rootPath: string
|
||||
connectionId?: string
|
||||
excludePaths?: string[]
|
||||
requestToken?: string
|
||||
}): Promise<string[]> => ipcRenderer.invoke('fs:listFiles', args),
|
||||
cancelListFiles: (args: { requestToken: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:cancelListFiles', args),
|
||||
search: (args: {
|
||||
query: string
|
||||
rootPath: string
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* a git-focused app.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
|
||||
import type { SearchOptions, SearchResult } from './fs-handler-utils'
|
||||
import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter'
|
||||
import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk'
|
||||
@@ -31,8 +32,13 @@ import { buildRelayCommandEnv } from './relay-command-env'
|
||||
*/
|
||||
export function listFilesWithGit(
|
||||
rootPath: string,
|
||||
excludePathPrefixes: readonly string[] = []
|
||||
excludePathPrefixes: readonly string[] = [],
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<string[]> {
|
||||
const { signal } = options
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(fileListingCancellationError(signal))
|
||||
}
|
||||
const gitPaths = new Set<string>()
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
const children: {
|
||||
@@ -145,7 +151,7 @@ export function listFilesWithGit(
|
||||
})
|
||||
}
|
||||
|
||||
const killSurvivors = (): void => {
|
||||
const killSurvivors = (reason: string): void => {
|
||||
// Why: Promise.all returns after the first failed pass, but the sibling
|
||||
// git process can keep streaming on SSH unless we cancel it explicitly.
|
||||
for (const entry of children) {
|
||||
@@ -155,22 +161,35 @@ export function listFilesWithGit(
|
||||
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
||||
entry.child.kill()
|
||||
}
|
||||
entry.reject(new Error('git ls-files canceled after sibling failure'))
|
||||
entry.reject(new Error(reason))
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a cancelled scan (workspace switch, superseded request) must stop
|
||||
// its git children right away instead of streaming a huge tree the caller
|
||||
// has already abandoned over the shared SSH channel.
|
||||
const onAbort = (): void => killSurvivors('git ls-files cancelled')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)])
|
||||
.then(() =>
|
||||
expandQuickOpenGitFilesWithNestedRepos({
|
||||
rootPath,
|
||||
gitPaths,
|
||||
excludePathPrefixes
|
||||
excludePathPrefixes,
|
||||
signal
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
killSurvivors()
|
||||
killSurvivors('git ls-files canceled after sibling failure')
|
||||
if (signal?.aborted) {
|
||||
throw fileListingCancellationError(signal)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Cancellation behavior of the relay file-list scanners (#7721): an aborted
|
||||
* scan must kill its child processes immediately and reject with a
|
||||
* cancellation error instead of streaming an abandoned tree to completion.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { listFilesWithRg } from './fs-handler-list-files'
|
||||
import { listFilesWithGit } from './fs-handler-git-fallback'
|
||||
import { isFileListingCancellation } from '../shared/file-listing-cancellation'
|
||||
|
||||
function createMockProcess(): ChildProcess {
|
||||
const p = new EventEmitter() as unknown as ChildProcess
|
||||
;(p as unknown as Record<string, unknown>).stdout = new EventEmitter()
|
||||
;(
|
||||
(p as unknown as Record<string, unknown>).stdout as EventEmitter & {
|
||||
setEncoding: () => void
|
||||
}
|
||||
).setEncoding = vi.fn()
|
||||
;(p as unknown as Record<string, unknown>).stderr = new EventEmitter()
|
||||
;(p as unknown as Record<string, unknown>).kill = vi.fn()
|
||||
;(p as unknown as Record<string, unknown>).exitCode = null
|
||||
;(p as unknown as Record<string, unknown>).signalCode = null
|
||||
return p
|
||||
}
|
||||
|
||||
describe('relay list-files cancellation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => {
|
||||
const primaryProc = createMockProcess()
|
||||
const ignoredProc = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
|
||||
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
|
||||
|
||||
// Partial output before the abort — must be discarded, not resolved.
|
||||
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(primaryProc.kill).toHaveBeenCalled()
|
||||
expect(ignoredProc.kill).toHaveBeenCalled()
|
||||
|
||||
// Late close events after cancellation must not fire anything.
|
||||
primaryProc.emit('close', null, 'SIGTERM')
|
||||
ignoredProc.emit('close', null, 'SIGTERM')
|
||||
})
|
||||
|
||||
it('listFilesWithRg rejects without spawning when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
listFilesWithRg('/remote/root', [], { signal: controller.signal })
|
||||
).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => {
|
||||
const primaryProc = createMockProcess()
|
||||
const ignoredProc = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
|
||||
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
|
||||
|
||||
setTimeout(() => {
|
||||
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
primaryProc.emit('close', 0, null)
|
||||
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n')
|
||||
ignoredProc.emit('close', 0, null)
|
||||
}, 5)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js'])
|
||||
})
|
||||
|
||||
it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => {
|
||||
const procs: ChildProcess[] = []
|
||||
spawnMock.mockImplementation(() => {
|
||||
const proc = createMockProcess()
|
||||
procs.push(proc)
|
||||
return proc
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal })
|
||||
|
||||
expect(procs).toHaveLength(2)
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(procs[0].kill).toHaveBeenCalled()
|
||||
expect(procs[1].kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
listFilesWithGit('/remote/root', [], { signal: controller.signal })
|
||||
).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@
|
||||
* denied on a single subdir is expected on home-dir roots)
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
|
||||
import {
|
||||
buildRgArgsForQuickOpen,
|
||||
normalizeQuickOpenRgLine,
|
||||
@@ -27,8 +28,13 @@ export const LIST_FILES_TIMEOUT_MS = 25_000
|
||||
|
||||
export function listFilesWithRg(
|
||||
rootPath: string,
|
||||
excludePathPrefixes: readonly string[] = []
|
||||
excludePathPrefixes: readonly string[] = [],
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<string[]> {
|
||||
const { signal } = options
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(fileListingCancellationError(signal))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const files = new Set<string>()
|
||||
let done = false
|
||||
@@ -177,7 +183,7 @@ export function listFilesWithRg(
|
||||
child.once('close', handleClose)
|
||||
})
|
||||
|
||||
const killSurvivors = (): void => {
|
||||
const killSurvivors = (reason: string): void => {
|
||||
// Why: when one pass rejects, Promise.all surfaces the error immediately
|
||||
// but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it
|
||||
// so repeated Quick Open opens don't pile up orphan rg processes on the
|
||||
@@ -189,16 +195,30 @@ export function listFilesWithRg(
|
||||
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
||||
entry.child.kill()
|
||||
}
|
||||
entry.reject(new Error('rg list canceled after sibling failure'))
|
||||
entry.reject(new Error(reason))
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a cancelled scan (workspace switch, superseded request) must stop
|
||||
// its rg children immediately instead of letting them walk the tree to
|
||||
// completion and flood the relay with stdout it will only discard.
|
||||
const onAbort = (): void => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
killSurvivors('rg list cancelled')
|
||||
reject(fileListingCancellationError(signal))
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
Promise.all([runPass(primary), runPass(ignoredPass)])
|
||||
.then(() => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(Array.from(files))
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -206,7 +226,8 @@ export function listFilesWithRg(
|
||||
return
|
||||
}
|
||||
done = true
|
||||
killSurvivors()
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
killSurvivors('rg list canceled after sibling failure')
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
*/
|
||||
export async function listFilesWithReaddir(
|
||||
rootPath: string,
|
||||
excludePathPrefixes: readonly string[] = []
|
||||
excludePathPrefixes: readonly string[] = [],
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<string[]> {
|
||||
return listQuickOpenFilesWithReaddir(rootPath, {
|
||||
excludePathPrefixes,
|
||||
budget: createQuickOpenReaddirBudget()
|
||||
budget: createQuickOpenReaddirBudget(),
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
|
||||
+33
-5
@@ -17,6 +17,11 @@ import {
|
||||
} from './fs-handler-utils'
|
||||
import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback'
|
||||
import { listFilesWithReaddir } from './fs-handler-readdir-fallback'
|
||||
import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator'
|
||||
import {
|
||||
isFileListingCancellation,
|
||||
throwIfFileListingCancelled
|
||||
} from '../shared/file-listing-cancellation'
|
||||
import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk'
|
||||
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
|
||||
import { buildInstallRgMessage } from './fs-handler-install-rg'
|
||||
@@ -82,6 +87,7 @@ export class FsHandler {
|
||||
private dispatcher: RelayDispatcher
|
||||
private watches = new Map<string, WatchState>()
|
||||
private streamRegistry = new RelayStreamRegistry()
|
||||
private listFilesScans = new ListFilesScanCoordinator()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
|
||||
this.dispatcher = dispatcher
|
||||
@@ -114,7 +120,7 @@ export class FsHandler {
|
||||
this.dispatcher.onRequest('fs.copy', (p) => this.copy(p))
|
||||
this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p))
|
||||
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
|
||||
this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p))
|
||||
this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
|
||||
this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
|
||||
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
|
||||
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
|
||||
@@ -331,16 +337,33 @@ export class FsHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private async listFiles(params: Record<string, unknown>): Promise<string[]> {
|
||||
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
|
||||
const rootPath = expandTilde(params.rootPath as string)
|
||||
// Why: the main-to-relay RPC adds excludePaths so nested linked worktrees
|
||||
// don't get double-scanned. The shared helper validates the shape and
|
||||
// normalizes into root-relative prefixes; malformed input yields [] so
|
||||
// the request still succeeds (older apps omit the field entirely).
|
||||
const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths)
|
||||
// Why #7721: full-tree scans are the relay's most expensive request; the
|
||||
// coordinator caps them at one per client, coalescing duplicates and
|
||||
// aborting a stale scan when the workspace changes or the host cancels.
|
||||
return this.listFilesScans.run({
|
||||
clientId: context?.clientId ?? 0,
|
||||
key: JSON.stringify([rootPath, excludePathPrefixes]),
|
||||
signal: context?.signal,
|
||||
start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal)
|
||||
})
|
||||
}
|
||||
|
||||
private async runListFilesScan(
|
||||
rootPath: string,
|
||||
excludePathPrefixes: string[],
|
||||
signal: AbortSignal
|
||||
): Promise<string[]> {
|
||||
const rgAvailable = await checkRgAvailable()
|
||||
throwIfFileListingCancelled(signal)
|
||||
if (rgAvailable) {
|
||||
return listFilesWithRg(rootPath, excludePathPrefixes)
|
||||
return listFilesWithRg(rootPath, excludePathPrefixes, { signal })
|
||||
}
|
||||
// Why: git ls-files only works inside git repos. Use rev-parse to detect
|
||||
// git ancestry — unlike checking for a local .git entry, this works from
|
||||
@@ -361,7 +384,7 @@ export class FsHandler {
|
||||
// budget errors into install-rg guidance; genuine git failures keep
|
||||
// their own messages.
|
||||
try {
|
||||
return await listFilesWithGit(rootPath, excludePathPrefixes)
|
||||
return await listFilesWithGit(rootPath, excludePathPrefixes, { signal })
|
||||
} catch (err) {
|
||||
if (isQuickOpenReaddirBudgetError(err)) {
|
||||
throw new Error(await buildInstallRgMessage(err))
|
||||
@@ -375,8 +398,13 @@ export class FsHandler {
|
||||
// problem, so translate the opaque cap error into actionable guidance
|
||||
// the user can act on directly from the error toast.
|
||||
try {
|
||||
return await listFilesWithReaddir(rootPath, excludePathPrefixes)
|
||||
return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal })
|
||||
} catch (err) {
|
||||
// Why: a cancelled scan is not an rg-availability problem; wrapping it
|
||||
// in install-rg guidance would surface bogus advice on the client.
|
||||
if (isFileListingCancellation(err)) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(await buildInstallRgMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* End-to-end in-process regression tests for #7721: cancellable fs.listFiles.
|
||||
*
|
||||
* Wires the client-side SshChannelMultiplexer to the relay-side
|
||||
* RelayDispatcher + FsHandler through an in-memory pipe (no SSH). The scan
|
||||
* body is a controllable fake so the tests are deterministic:
|
||||
* - aborting the client request sends rpc.cancel and stops the relay scan,
|
||||
* - interactive fs.readDir is served while a scan is in flight,
|
||||
* - a scan for a different workspace supersedes the previous one,
|
||||
* - identical concurrent requests coalesce into one scan.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const { fakeListFiles } = vi.hoisted(() => {
|
||||
type ScanRecord = {
|
||||
rootPath: string
|
||||
signal: AbortSignal | undefined
|
||||
resolve: (files: string[]) => void
|
||||
}
|
||||
const scans: ScanRecord[] = []
|
||||
const fakeListFiles = Object.assign(
|
||||
vi.fn(
|
||||
(
|
||||
rootPath: string,
|
||||
_excludes: readonly string[] = [],
|
||||
options: { signal?: AbortSignal } = {}
|
||||
) =>
|
||||
new Promise<string[]>((resolve, reject) => {
|
||||
scans.push({ rootPath, signal: options.signal, resolve })
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
// Mirror the real scanners: surface the abort reason (e.g. the
|
||||
// "superseded" error) so the dispatcher reports it to the host.
|
||||
reject(
|
||||
options.signal?.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('File listing cancelled')
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
),
|
||||
{ scans }
|
||||
)
|
||||
return { fakeListFiles }
|
||||
})
|
||||
|
||||
vi.mock('./fs-handler-utils', async (importOriginal) => {
|
||||
const original = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...original,
|
||||
checkRgAvailable: () => Promise.resolve(true),
|
||||
listFilesWithRg: fakeListFiles
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
SshChannelMultiplexer,
|
||||
type MultiplexerTransport
|
||||
} from '../main/ssh/ssh-channel-multiplexer'
|
||||
import { RelayDispatcher } from './dispatcher'
|
||||
import { RelayContext } from './context'
|
||||
import { FsHandler } from './fs-handler'
|
||||
import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator'
|
||||
|
||||
async function flushPipe(): Promise<void> {
|
||||
// The in-memory pipe defers each hop with setImmediate; a few macrotask
|
||||
// turns guarantee request/notification frames have crossed both directions.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
describe('Integration: cancellable fs.listFiles (#7721)', () => {
|
||||
let tmpDir: string
|
||||
let mux: SshChannelMultiplexer
|
||||
let dispatcher: RelayDispatcher
|
||||
let fsHandler: FsHandler
|
||||
|
||||
beforeEach(() => {
|
||||
fakeListFiles.mockClear()
|
||||
fakeListFiles.scans.length = 0
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-listfiles-cancel-'))
|
||||
|
||||
let relayFeedFn: (data: Buffer) => void
|
||||
const clientDataCallbacks: ((data: Buffer) => void)[] = []
|
||||
const clientTransport: MultiplexerTransport = {
|
||||
write: (data: Buffer) => {
|
||||
setImmediate(() => relayFeedFn?.(data))
|
||||
},
|
||||
onData: (cb) => {
|
||||
clientDataCallbacks.push(cb)
|
||||
},
|
||||
onClose: () => {}
|
||||
}
|
||||
dispatcher = new RelayDispatcher((data: Buffer) => {
|
||||
setImmediate(() => {
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
relayFeedFn = (data: Buffer) => dispatcher.feed(data)
|
||||
fsHandler = new FsHandler(dispatcher, new RelayContext())
|
||||
mux = new SshChannelMultiplexer(clientTransport)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mux.dispose()
|
||||
dispatcher.dispose()
|
||||
fsHandler.dispose()
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('serves fs.readDir while a full-tree scan is in flight', async () => {
|
||||
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
|
||||
|
||||
const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' })
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans).toHaveLength(1)
|
||||
|
||||
// The interactive request must complete while the scan is still pending.
|
||||
const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[]
|
||||
expect(entries.map((e) => e.name)).toEqual(['a.txt'])
|
||||
|
||||
fakeListFiles.scans[0].resolve(['src/index.ts'])
|
||||
await expect(scanPromise).resolves.toEqual(['src/index.ts'])
|
||||
})
|
||||
|
||||
it('client abort sends rpc.cancel and stops the relay-side scan', async () => {
|
||||
const controller = new AbortController()
|
||||
const scanPromise = mux.request(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/big/workspace' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans).toHaveLength(1)
|
||||
expect(fakeListFiles.scans[0].signal?.aborted).toBe(false)
|
||||
|
||||
controller.abort()
|
||||
await expect(scanPromise).rejects.toThrow('was cancelled')
|
||||
await flushPipe()
|
||||
|
||||
// The rpc.cancel notification must have aborted the scan on the relay.
|
||||
expect(fakeListFiles.scans[0].signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('a scan for a different workspace supersedes the previous scan', async () => {
|
||||
const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
|
||||
// Attach the rejection handler up front: the error response arrives on a
|
||||
// macrotask inside flushPipe, before any later `await expect` could.
|
||||
const firstOutcome = first.then(
|
||||
() => null,
|
||||
(err: Error) => err
|
||||
)
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans).toHaveLength(1)
|
||||
|
||||
const second = mux.request('fs.listFiles', { rootPath: '/workspace/b' })
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans).toHaveLength(2)
|
||||
|
||||
// The stale scan is aborted and its request fails fast — it does not run
|
||||
// to completion behind the new one and does not hit the 30s timeout.
|
||||
expect(fakeListFiles.scans[0].signal?.aborted).toBe(true)
|
||||
const firstError = await firstOutcome
|
||||
expect(firstError?.message).toContain(LIST_FILES_SUPERSEDED_MESSAGE)
|
||||
|
||||
fakeListFiles.scans[1].resolve(['b.ts'])
|
||||
await expect(second).resolves.toEqual(['b.ts'])
|
||||
})
|
||||
|
||||
it('identical concurrent requests coalesce into a single scan', async () => {
|
||||
const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
|
||||
const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
|
||||
await flushPipe()
|
||||
|
||||
expect(fakeListFiles.scans).toHaveLength(1)
|
||||
fakeListFiles.scans[0].resolve(['shared.ts'])
|
||||
await expect(first).resolves.toEqual(['shared.ts'])
|
||||
await expect(second).resolves.toEqual(['shared.ts'])
|
||||
})
|
||||
|
||||
it('cancelling one coalesced requester keeps the scan alive for the other', async () => {
|
||||
const controller = new AbortController()
|
||||
const first = mux.request(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/workspace/a' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans).toHaveLength(1)
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toThrow('was cancelled')
|
||||
await flushPipe()
|
||||
expect(fakeListFiles.scans[0].signal?.aborted).toBe(false)
|
||||
|
||||
fakeListFiles.scans[0].resolve(['still.ts'])
|
||||
await expect(second).resolves.toEqual(['still.ts'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LIST_FILES_SUPERSEDED_MESSAGE,
|
||||
ListFilesScanCoordinator
|
||||
} from './fs-list-files-scan-coordinator'
|
||||
import {
|
||||
FileListingCancelledError,
|
||||
isFileListingCancellation
|
||||
} from '../shared/file-listing-cancellation'
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<string[]>
|
||||
resolve: (files: string[]) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve!: (files: string[]) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<string[]>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Scan runner that resolves/rejects on demand and rejects when aborted. */
|
||||
function controllableScan(): {
|
||||
start: (signal: AbortSignal) => Promise<string[]>
|
||||
starts: AbortSignal[]
|
||||
finish: (files: string[]) => void
|
||||
} {
|
||||
const starts: AbortSignal[] = []
|
||||
let current: Deferred | null = null
|
||||
return {
|
||||
starts,
|
||||
start: (signal: AbortSignal) => {
|
||||
starts.push(signal)
|
||||
const scan = deferred()
|
||||
current = scan
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
scan.reject(
|
||||
signal.reason instanceof Error ? signal.reason : new FileListingCancelledError()
|
||||
)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
return scan.promise
|
||||
},
|
||||
finish: (files: string[]) => current?.resolve(files)
|
||||
}
|
||||
}
|
||||
|
||||
describe('ListFilesScanCoordinator', () => {
|
||||
it('coalesces same-key concurrent requests into one scan', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
|
||||
const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
|
||||
expect(scan.starts).toHaveLength(1)
|
||||
scan.finish(['x.ts'])
|
||||
await expect(first).resolves.toEqual(['x.ts'])
|
||||
await expect(second).resolves.toEqual(['x.ts'])
|
||||
})
|
||||
|
||||
it('supersedes a different-key scan: aborts the old one and rejects it fast', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
|
||||
const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scan.start })
|
||||
const second = coordinator.run({ clientId: 1, key: 'workspace-b', start: scan.start })
|
||||
|
||||
expect(scan.starts).toHaveLength(2)
|
||||
expect(scan.starts[0].aborted).toBe(true)
|
||||
await expect(first).rejects.toThrow(LIST_FILES_SUPERSEDED_MESSAGE)
|
||||
await first.catch((err) => expect(isFileListingCancellation(err)).toBe(true))
|
||||
|
||||
expect(scan.starts[1].aborted).toBe(false)
|
||||
scan.finish(['b.ts'])
|
||||
await expect(second).resolves.toEqual(['b.ts'])
|
||||
})
|
||||
|
||||
it('keeps scans from different clients independent', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scanA = controllableScan()
|
||||
const scanB = controllableScan()
|
||||
|
||||
const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scanA.start })
|
||||
const second = coordinator.run({ clientId: 2, key: 'workspace-b', start: scanB.start })
|
||||
|
||||
expect(scanA.starts[0].aborted).toBe(false)
|
||||
scanA.finish(['a.ts'])
|
||||
scanB.finish(['b.ts'])
|
||||
await expect(first).resolves.toEqual(['a.ts'])
|
||||
await expect(second).resolves.toEqual(['b.ts'])
|
||||
})
|
||||
|
||||
it('aborts the scan when its only requester cancels', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
const requester = new AbortController()
|
||||
|
||||
const result = coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: requester.signal,
|
||||
start: scan.start
|
||||
})
|
||||
|
||||
requester.abort()
|
||||
expect(scan.starts[0].aborted).toBe(true)
|
||||
await expect(result).rejects.toSatisfy(isFileListingCancellation)
|
||||
})
|
||||
|
||||
it('keeps a coalesced scan alive while another requester still waits', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
|
||||
const firstResult = coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: first.signal,
|
||||
start: scan.start
|
||||
})
|
||||
const secondResult = coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: second.signal,
|
||||
start: scan.start
|
||||
})
|
||||
|
||||
first.abort()
|
||||
expect(scan.starts[0].aborted).toBe(false)
|
||||
// The aborting requester observes its own cancellation immediately,
|
||||
// while the shared scan keeps running for the sibling.
|
||||
await expect(firstResult).rejects.toSatisfy(isFileListingCancellation)
|
||||
|
||||
second.abort()
|
||||
expect(scan.starts[0].aborted).toBe(true)
|
||||
await expect(secondResult).rejects.toSatisfy(isFileListingCancellation)
|
||||
})
|
||||
|
||||
it('rejects an aborted coalesced requester while the sibling still resolves', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
const first = new AbortController()
|
||||
|
||||
const firstResult = coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: first.signal,
|
||||
start: scan.start
|
||||
})
|
||||
const secondResult = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
|
||||
first.abort()
|
||||
scan.finish(['kept.ts'])
|
||||
|
||||
await expect(firstResult).rejects.toSatisfy(isFileListingCancellation)
|
||||
await expect(secondResult).resolves.toEqual(['kept.ts'])
|
||||
})
|
||||
|
||||
it('rejects immediately when the requester is already cancelled', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const start = vi.fn()
|
||||
const requester = new AbortController()
|
||||
requester.abort()
|
||||
|
||||
await expect(
|
||||
coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: requester.signal,
|
||||
start: start as unknown as (signal: AbortSignal) => Promise<string[]>
|
||||
})
|
||||
).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a fresh scan for the same key after the previous one settled', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
|
||||
const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
scan.finish(['old.ts'])
|
||||
await expect(first).resolves.toEqual(['old.ts'])
|
||||
|
||||
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
expect(scan.starts).toHaveLength(2)
|
||||
scan.finish(['new.ts'])
|
||||
await expect(second).resolves.toEqual(['new.ts'])
|
||||
})
|
||||
|
||||
it('does not join a scan that is already aborted; starts a replacement', async () => {
|
||||
const coordinator = new ListFilesScanCoordinator()
|
||||
const scan = controllableScan()
|
||||
const requester = new AbortController()
|
||||
|
||||
const first = coordinator.run({
|
||||
clientId: 1,
|
||||
key: 'a',
|
||||
signal: requester.signal,
|
||||
start: scan.start
|
||||
})
|
||||
requester.abort()
|
||||
await expect(first).rejects.toSatisfy(isFileListingCancellation)
|
||||
|
||||
// The aborted entry may still be in the map until its promise settles;
|
||||
// a new same-key request must get a live scan, not the dead one.
|
||||
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
|
||||
expect(scan.starts).toHaveLength(2)
|
||||
expect(scan.starts[1].aborted).toBe(false)
|
||||
scan.finish(['fresh.ts'])
|
||||
await expect(second).resolves.toEqual(['fresh.ts'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Single-flight coordinator for fs.listFiles full-tree scans (#7721).
|
||||
*
|
||||
* Why: rapid workspace switching used to stack N concurrent full-tree scans
|
||||
* on the single-threaded relay and its one SSH channel, starving small
|
||||
* interactive fs.readDir/fs.stat requests past their 30s timeout. This
|
||||
* coordinator guarantees at most one scan in flight per client:
|
||||
* - a request for the same root/excludes joins the in-flight scan
|
||||
* (Quick Open + file-explorer filter share one scan),
|
||||
* - a request for a different root supersedes it — the old scan is aborted
|
||||
* and its request fails fast instead of running to completion (this also
|
||||
* protects against older Orca clients that never send rpc.cancel),
|
||||
* - when every joined requester cancels (rpc.cancel / client detach), the
|
||||
* scan is aborted so abandoned work stops immediately.
|
||||
*/
|
||||
import {
|
||||
FileListingCancelledError,
|
||||
fileListingCancellationError
|
||||
} from '../shared/file-listing-cancellation'
|
||||
|
||||
export const LIST_FILES_SUPERSEDED_MESSAGE = 'File listing superseded by a newer request'
|
||||
|
||||
type ScanEntry = {
|
||||
key: string
|
||||
controller: AbortController
|
||||
promise: Promise<string[]>
|
||||
attachedCount: number
|
||||
}
|
||||
|
||||
export class ListFilesScanCoordinator {
|
||||
private readonly scansByClient = new Map<number, ScanEntry>()
|
||||
|
||||
run(opts: {
|
||||
clientId: number
|
||||
key: string
|
||||
signal?: AbortSignal
|
||||
start: (signal: AbortSignal) => Promise<string[]>
|
||||
}): Promise<string[]> {
|
||||
const { clientId, key, signal, start } = opts
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(fileListingCancellationError(signal))
|
||||
}
|
||||
|
||||
const existing = this.scansByClient.get(clientId)
|
||||
if (existing && existing.key === key && !existing.controller.signal.aborted) {
|
||||
return this.attach(existing, signal)
|
||||
}
|
||||
if (existing) {
|
||||
existing.controller.abort(new FileListingCancelledError(LIST_FILES_SUPERSEDED_MESSAGE))
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const entry: ScanEntry = {
|
||||
key,
|
||||
controller,
|
||||
promise: Promise.resolve([]),
|
||||
attachedCount: 0
|
||||
}
|
||||
this.scansByClient.set(clientId, entry)
|
||||
entry.promise = start(controller.signal).finally(() => {
|
||||
if (this.scansByClient.get(clientId) === entry) {
|
||||
this.scansByClient.delete(clientId)
|
||||
}
|
||||
})
|
||||
return this.attach(entry, signal)
|
||||
}
|
||||
|
||||
private attach(entry: ScanEntry, signal?: AbortSignal): Promise<string[]> {
|
||||
entry.attachedCount++
|
||||
if (!signal) {
|
||||
return entry.promise
|
||||
}
|
||||
// Why: an aborting requester must see its own cancellation even though
|
||||
// the shared scan keeps running (and may later resolve) for coalesced
|
||||
// siblings — so each attachment gets its own promise.
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
let settled = false
|
||||
const settle = (complete: () => void): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
complete()
|
||||
}
|
||||
const onAbort = (): void =>
|
||||
settle(() => {
|
||||
entry.attachedCount--
|
||||
const cancellation = fileListingCancellationError(signal)
|
||||
// Why: only stop the shared scan when nobody is left waiting on it —
|
||||
// one requester cancelling must not break a coalesced sibling.
|
||||
if (entry.attachedCount <= 0) {
|
||||
entry.controller.abort(cancellation)
|
||||
}
|
||||
reject(cancellation)
|
||||
})
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
entry.promise.then(
|
||||
(files) => settle(() => resolve(files)),
|
||||
(error) => settle(() => reject(error))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
|
||||
describe('relayLogLine', () => {
|
||||
it('prefixes each log line with an ISO timestamp', () => {
|
||||
const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
try {
|
||||
relayLogLine('[relay] Grace started (stdin ended)')
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const line = writeSpy.mock.calls[0][0] as string
|
||||
// Why: relay.log correlation depends on a grep-stable
|
||||
// "<ISO timestamp> <original line>" shape (#7773).
|
||||
expect(line).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/
|
||||
)
|
||||
} finally {
|
||||
writeSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
// Why: the relay daemon's stderr is redirected to relay.log on the remote
|
||||
// host. Without timestamps, reconnect flaps in that log cannot be correlated
|
||||
// with user activity or sleep/wake windows during diagnosis (#7773). Keep the
|
||||
// format grep-stable: ISO timestamp, single space, then the original line.
|
||||
export function relayLogLine(message: string): void {
|
||||
process.stderr.write(`${new Date().toISOString()} ${message}\n`)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseHandshakeMessage,
|
||||
type DecodedFrame
|
||||
} from './protocol'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
|
||||
// Why: a unique exit code reserved for the wire-level version-mismatch terminal
|
||||
// condition. The client (waitForSentinel + ssh.ts) maps this exit code to a
|
||||
@@ -132,22 +133,18 @@ function handleDaemonHandshakeFrame(
|
||||
try {
|
||||
msg = parseHandshakeMessage(frame.payload)
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n`
|
||||
)
|
||||
relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`)
|
||||
sock.destroy()
|
||||
return false
|
||||
}
|
||||
if (msg.type !== 'orca-relay-handshake') {
|
||||
process.stderr.write(
|
||||
`[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n`
|
||||
)
|
||||
relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`)
|
||||
sock.destroy()
|
||||
return false
|
||||
}
|
||||
if (msg.version !== launchVersion) {
|
||||
process.stderr.write(
|
||||
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n`
|
||||
relayLogLine(
|
||||
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket`
|
||||
)
|
||||
try {
|
||||
sock.write(
|
||||
|
||||
+24
-25
@@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi
|
||||
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
|
||||
import { pickRemoteCliEnv } from './remote-cli-env'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
|
||||
@@ -358,13 +359,13 @@ async function main(): Promise<void> {
|
||||
// would risk silent data corruption or zombie PTYs. We log for diagnostics
|
||||
// and then exit so the client can detect the disconnect and reconnect cleanly.
|
||||
process.on('uncaughtException', (err) => {
|
||||
process.stderr.write(`[relay] Uncaught exception: ${err.message}\n${err.stack}\n`)
|
||||
relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`)
|
||||
cleanupOwnedSocket()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
process.stderr.write(`[relay] Unhandled rejection: ${reason}\n`)
|
||||
relayLogLine(`[relay] Unhandled rejection: ${reason}`)
|
||||
})
|
||||
|
||||
// Why: stdoutAlive tracks whether process.stdout is still writable.
|
||||
@@ -529,8 +530,8 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
await hookServer.start({ publishEndpoint: false })
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
relayLogLine(
|
||||
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -688,7 +689,7 @@ async function main(): Promise<void> {
|
||||
|
||||
function cancelGrace(reason: string): void {
|
||||
if (ptyHandler.graceTimerActive) {
|
||||
process.stderr.write(`[relay] Grace canceled: ${reason}\n`)
|
||||
relayLogLine(`[relay] Grace canceled: ${reason}`)
|
||||
}
|
||||
graceDeadlineAt = null
|
||||
graceReason = null
|
||||
@@ -704,8 +705,8 @@ async function main(): Promise<void> {
|
||||
|
||||
hasAcceptedSocketClient = true
|
||||
acceptedSocketConnections++
|
||||
process.stderr.write(
|
||||
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})\n`
|
||||
relayLogLine(
|
||||
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})`
|
||||
)
|
||||
cancelGrace('socket client accepted')
|
||||
|
||||
@@ -779,7 +780,7 @@ async function main(): Promise<void> {
|
||||
if (clientId !== undefined) {
|
||||
dispatcher.detachClient(clientId)
|
||||
}
|
||||
process.stderr.write(`[relay] Socket client closed (clients=${socketClients.size})\n`)
|
||||
relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`)
|
||||
if (!stdoutAlive && socketClients.size === 0) {
|
||||
startGrace('socket client closed')
|
||||
}
|
||||
@@ -821,9 +822,9 @@ async function main(): Promise<void> {
|
||||
ownsSocketPath = true
|
||||
ownedSocketIdentity = readSocketIdentity(sockPath)
|
||||
server.on('error', (err) => {
|
||||
process.stderr.write(`[relay] Socket server error: ${err.message}\n`)
|
||||
relayLogLine(`[relay] Socket server error: ${err.message}`)
|
||||
})
|
||||
process.stderr.write(`[relay] Socket server listening: ${sockPath}\n`)
|
||||
relayLogLine(`[relay] Socket server listening: ${sockPath}`)
|
||||
resolve()
|
||||
}
|
||||
|
||||
@@ -831,11 +832,11 @@ async function main(): Promise<void> {
|
||||
removeStartupListeners()
|
||||
restoreUmask()
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
process.stderr.write(
|
||||
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.\n`
|
||||
relayLogLine(
|
||||
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.`
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`[relay] Socket server error before listen: ${err.message}\n`)
|
||||
relayLogLine(`[relay] Socket server error before listen: ${err.message}`)
|
||||
}
|
||||
reject(err)
|
||||
}
|
||||
@@ -903,9 +904,7 @@ async function main(): Promise<void> {
|
||||
failInitial(err)
|
||||
return
|
||||
}
|
||||
process.stderr.write(
|
||||
`[relay] Removed stale socket at ${sockPath} and retrying listen\n`
|
||||
)
|
||||
relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`)
|
||||
removeStartupListeners()
|
||||
listenForStartupError(failInitial)
|
||||
})
|
||||
@@ -958,11 +957,11 @@ async function main(): Promise<void> {
|
||||
: graceTimeMs
|
||||
graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs
|
||||
graceReason = reason
|
||||
process.stderr.write(
|
||||
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}\n`
|
||||
relayLogLine(
|
||||
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}`
|
||||
)
|
||||
ptyHandler.startGraceTimer(() => {
|
||||
process.stderr.write(`[relay] Grace expired (${reason}); shutting down\n`)
|
||||
relayLogLine(`[relay] Grace expired (${reason}); shutting down`)
|
||||
shutdown()
|
||||
}, timeoutMs)
|
||||
}
|
||||
@@ -1006,8 +1005,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
function shutdown(): void {
|
||||
process.stderr.write(
|
||||
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}\n`
|
||||
relayLogLine(
|
||||
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}`
|
||||
)
|
||||
graceDeadlineAt = null
|
||||
graceReason = null
|
||||
@@ -1034,10 +1033,10 @@ async function main(): Promise<void> {
|
||||
// window — a reconnecting client can then bridge to the live relay via
|
||||
// --connect and reattach to the still-running PTY sessions.
|
||||
process.on('SIGHUP', () => {
|
||||
process.stderr.write('[relay] Received SIGHUP (SSH session dropped), ignoring\n')
|
||||
relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring')
|
||||
})
|
||||
process.on('exit', (code) => {
|
||||
process.stderr.write(`[relay] Process exiting with code ${code}\n`)
|
||||
relayLogLine(`[relay] Process exiting with code ${code}`)
|
||||
})
|
||||
|
||||
// Signal readiness to the client — the client watches for this exact
|
||||
@@ -1059,8 +1058,8 @@ function cleanupSocket(sockPath: string): void {
|
||||
}
|
||||
|
||||
void main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
|
||||
relayLogLine(
|
||||
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
|
||||
)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent'
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
vi.mock('@/components/editor/ReviewNotesSendMenuContent', () => ({
|
||||
ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record<string, unknown>) {
|
||||
return { type: 'ReviewNotesSendMenuContent', props }
|
||||
}
|
||||
}))
|
||||
|
||||
function expand(node: unknown): unknown {
|
||||
if (node == null || typeof node === 'string' || typeof node === 'number') {
|
||||
return node
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((entry) => expand(entry))
|
||||
}
|
||||
if (!React.isValidElement(node)) {
|
||||
if (typeof node === 'object' && 'props' in node) {
|
||||
const element = node as ReactElementLike
|
||||
return {
|
||||
...element,
|
||||
props: {
|
||||
...element.props,
|
||||
children: expand(element.props.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
const element = node as React.ReactElement<Record<string, unknown>>
|
||||
if (typeof element.type === 'function') {
|
||||
const Component = element.type as (props: Record<string, unknown>) => unknown
|
||||
return expand(Component(element.props))
|
||||
}
|
||||
return {
|
||||
type: element.type,
|
||||
props: {
|
||||
...element.props,
|
||||
children: expand(element.props.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findByType(node: unknown, type: string): ReactElementLike {
|
||||
if (node && typeof node === 'object' && 'type' in node) {
|
||||
const element = node as ReactElementLike
|
||||
if (element.type === type) {
|
||||
return element
|
||||
}
|
||||
const children = element.props.children
|
||||
if (Array.isArray(children)) {
|
||||
for (const child of children) {
|
||||
try {
|
||||
return findByType(child, type)
|
||||
} catch {
|
||||
// Continue searching siblings.
|
||||
}
|
||||
}
|
||||
} else if (children) {
|
||||
return findByType(children, type)
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to find ${type}`)
|
||||
}
|
||||
|
||||
describe('BrowserAnnotationSendMenuContent', () => {
|
||||
it('uses the review notes send content so existing agent sessions are selectable', () => {
|
||||
const onPromptDelivered = vi.fn()
|
||||
const tree = expand(
|
||||
<BrowserAnnotationSendMenuContent
|
||||
worktreeId="wt-1"
|
||||
groupId="group-1"
|
||||
prompt="browser annotation prompt"
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(findByType(tree, 'ReviewNotesSendMenuContent').props).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
prompt: 'browser annotation prompt',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered
|
||||
})
|
||||
})
|
||||
|
||||
it('is wired into both browser annotation send surfaces', () => {
|
||||
const browserPaneSource = readFileSync(
|
||||
fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(browserPaneSource.match(/<BrowserAnnotationSendMenuContent\b/g)).toHaveLength(2)
|
||||
expect(browserPaneSource).not.toContain('<QuickLaunchAgentMenuItems')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent'
|
||||
|
||||
export type BrowserAnnotationSendMenuContentProps = {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
prompt: string
|
||||
onPromptDelivered?: () => void
|
||||
}
|
||||
|
||||
export function BrowserAnnotationSendMenuContent({
|
||||
worktreeId,
|
||||
groupId,
|
||||
prompt,
|
||||
onPromptDelivered
|
||||
}: BrowserAnnotationSendMenuContentProps): React.JSX.Element {
|
||||
return (
|
||||
<ReviewNotesSendMenuContent
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
prompt={prompt}
|
||||
// Why: keep browser-annotation delivery and telemetry stable even if
|
||||
// the shared review-notes menu defaults change later.
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -45,8 +45,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -5297,7 +5296,7 @@ function BrowserPagePane({
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.95af781091',
|
||||
'Send feedback to a new agent'
|
||||
'Send feedback to an agent'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -5307,13 +5306,10 @@ function BrowserPagePane({
|
||||
onInteractOutside={preventAgentSendTargetOutsideDismiss}
|
||||
onPointerDownOutside={preventAgentSendTargetOutsideDismiss}
|
||||
>
|
||||
<QuickLaunchAgentMenuItems
|
||||
<BrowserAnnotationSendMenuContent
|
||||
worktreeId={worktreeId}
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
@@ -5571,7 +5567,7 @@ function BrowserPagePane({
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.95af781091',
|
||||
'Send feedback to a new agent'
|
||||
'Send feedback to an agent'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -5581,13 +5577,10 @@ function BrowserPagePane({
|
||||
onInteractOutside={preventAgentSendTargetOutsideDismiss}
|
||||
onPointerDownOutside={preventAgentSendTargetOutsideDismiss}
|
||||
>
|
||||
<QuickLaunchAgentMenuItems
|
||||
<BrowserAnnotationSendMenuContent
|
||||
worktreeId={worktreeId}
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { AppState } from '@/store/types'
|
||||
import { useRuntimeFileListForWorktree, type RuntimeFileListState } from './quick-open-file-list'
|
||||
|
||||
const listRuntimeFilesMock = vi.hoisted(() => vi.fn())
|
||||
const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
listRuntimeFiles: listRuntimeFilesMock
|
||||
listRuntimeFiles: listRuntimeFilesMock,
|
||||
cancelRuntimeFileList: cancelRuntimeFileListMock
|
||||
}))
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
@@ -104,6 +106,7 @@ async function renderProbe(args: {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(initialAppState, true)
|
||||
listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json'])
|
||||
cancelRuntimeFileListMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -155,9 +158,37 @@ describe('useRuntimeFileListForWorktree', () => {
|
||||
}),
|
||||
{
|
||||
rootPath: '/srv/platform',
|
||||
excludePaths: undefined
|
||||
excludePaths: undefined,
|
||||
requestToken: expect.any(String)
|
||||
}
|
||||
)
|
||||
expect(states.at(-1)?.files).toEqual(['packages/app/package.json'])
|
||||
})
|
||||
|
||||
it('cancels the in-flight scan with the same request token on unmount (#7721)', async () => {
|
||||
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
|
||||
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })],
|
||||
projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const root = await renderProbe({
|
||||
enabled: true,
|
||||
onState: () => {},
|
||||
worktreeId: workspaceKey
|
||||
})
|
||||
await waitForListRuntimeFilesCall()
|
||||
|
||||
const [listContext, listRequest] = listRuntimeFilesMock.mock.calls[0]
|
||||
expect(cancelRuntimeFileListMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Worktree } from '../../../shared/types'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { listRuntimeFiles } from '@/runtime/runtime-file-client'
|
||||
import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useWorktreesForRepo } from '@/store/selectors'
|
||||
|
||||
@@ -152,21 +153,21 @@ export function useRuntimeFileListForWorktree({
|
||||
setLoading(true)
|
||||
|
||||
const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined
|
||||
const requestToken = createBrowserUuid()
|
||||
const requestContext = {
|
||||
// Why: Quick Open lists files for the selected workspace. It must
|
||||
// follow that workspace's owner host, not the globally focused host.
|
||||
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
}
|
||||
|
||||
void listRuntimeFiles(
|
||||
{
|
||||
// Why: Quick Open lists files for the selected workspace. It must
|
||||
// follow that workspace's owner host, not the globally focused host.
|
||||
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
{
|
||||
rootPath: worktreePath,
|
||||
excludePaths
|
||||
}
|
||||
)
|
||||
void listRuntimeFiles(requestContext, {
|
||||
rootPath: worktreePath,
|
||||
excludePaths,
|
||||
requestToken
|
||||
})
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setFiles(result)
|
||||
@@ -186,6 +187,11 @@ export function useRuntimeFileListForWorktree({
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Why #7721: switching workspaces (or closing the palette) must abort
|
||||
// the previous full-tree scan host- and relay-side. Over SSH, abandoned
|
||||
// scans otherwise stack up and starve fs.readDir/fs.stat past their
|
||||
// 30s timeout ("Could not load files for this workspace").
|
||||
cancelRuntimeFileList(requestContext, requestToken)
|
||||
}
|
||||
}, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath])
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
|
||||
import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
|
||||
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
|
||||
import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
AI_VAULT_AGENTS,
|
||||
type AiVaultAgent,
|
||||
@@ -41,7 +40,11 @@ import { translate } from '@/i18n/i18n'
|
||||
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
|
||||
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
|
||||
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
|
||||
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
|
||||
import {
|
||||
buildAiVaultHostScopeOptions,
|
||||
buildRuntimeAiVaultHostScopeOptions,
|
||||
useAiVaultExecutionHostScope
|
||||
} from './ai-vault-host-scope'
|
||||
|
||||
export default function AiVaultPanel(): React.JSX.Element {
|
||||
const activeWorktreeId = useActiveWorktreeId()
|
||||
@@ -59,6 +62,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
}))
|
||||
)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
|
||||
const agentCmdOverrides = settings?.agentCmdOverrides
|
||||
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
|
||||
useAiVaultOriginalPaneActions()
|
||||
@@ -72,17 +76,28 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const userChangedScopeRef = useRef(false)
|
||||
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
|
||||
|
||||
const isRuntimeWorktree = useMemo(
|
||||
() =>
|
||||
getAiVaultResumeWorkspaceTargetStatus(resumeTargetState, activeWorktreeId ?? null) ===
|
||||
'runtime',
|
||||
[activeWorktreeId, resumeTargetState]
|
||||
const runtimeHostOptions = useMemo(
|
||||
() => buildRuntimeAiVaultHostScopeOptions(runtimeEnvironments),
|
||||
[runtimeEnvironments]
|
||||
)
|
||||
const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } =
|
||||
const availableExecutionHostScopes = useMemo(
|
||||
() => runtimeHostOptions.map((option) => option.id),
|
||||
[runtimeHostOptions]
|
||||
)
|
||||
const { executionHostScope, activeExecutionHostScope, onExecutionHostScopeChange } =
|
||||
useAiVaultExecutionHostScope({
|
||||
activeWorktreeId: activeWorktreeId ?? null,
|
||||
resumeTargetState
|
||||
resumeTargetState,
|
||||
availableExecutionHostScopes
|
||||
})
|
||||
const hostScopeOptions = useMemo(
|
||||
() =>
|
||||
buildAiVaultHostScopeOptions({
|
||||
activeExecutionHostScope,
|
||||
runtimeHostOptions
|
||||
}),
|
||||
[activeExecutionHostScope, runtimeHostOptions]
|
||||
)
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
|
||||
const activeWorktreePaths = useMemo(
|
||||
@@ -306,7 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
activeProjectKey={activeProjectKey}
|
||||
scope={scope}
|
||||
executionHostScope={executionHostScope}
|
||||
activeSshExecutionHostScope={activeSshExecutionHostScope}
|
||||
hostScopeOptions={hostScopeOptions}
|
||||
agents={agents}
|
||||
sort={sort}
|
||||
group={group}
|
||||
@@ -323,15 +338,6 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
onRefresh={() => void refresh({ force: true })}
|
||||
/>
|
||||
|
||||
{isRuntimeWorktree ? (
|
||||
<div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeBrowseLocalHistory',
|
||||
'Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -32,14 +32,10 @@ import {
|
||||
type AiVaultScope,
|
||||
type AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
getExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
type ExecutionHostScope
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
|
||||
|
||||
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
|
||||
|
||||
@@ -184,19 +180,15 @@ export function VaultScopeSwitch({
|
||||
|
||||
export function VaultHostScopeMenu({
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
hostOptions,
|
||||
onExecutionHostScopeChange
|
||||
}: {
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
hostOptions: readonly AiVaultHostScopeOption[]
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
}): React.JSX.Element {
|
||||
const hostOptions = [
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []),
|
||||
ALL_EXECUTION_HOSTS_SCOPE
|
||||
] as const
|
||||
const label = getExecutionHostLabel(executionHostScope)
|
||||
const selectedOption = hostOptions.find((option) => option.id === executionHostScope)
|
||||
const label = selectedOption?.label ?? getExecutionHostLabel(executionHostScope)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -224,9 +216,9 @@ export function VaultHostScopeMenu({
|
||||
value={executionHostScope}
|
||||
onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)}
|
||||
>
|
||||
{hostOptions.map((scope) => (
|
||||
<DropdownMenuRadioItem key={scope} value={scope}>
|
||||
{getExecutionHostLabel(scope)}
|
||||
{hostOptions.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
|
||||
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
|
||||
|
||||
type AiVaultPanelHeaderProps = {
|
||||
query: string
|
||||
@@ -20,7 +21,7 @@ type AiVaultPanelHeaderProps = {
|
||||
activeProjectKey: string | null
|
||||
scope: AiVaultScope
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
hostScopeOptions: readonly AiVaultHostScopeOption[]
|
||||
agents: readonly AiVaultAgent[]
|
||||
sort: AiVaultSort
|
||||
group: AiVaultGroup
|
||||
@@ -47,7 +48,7 @@ export function AiVaultPanelHeader({
|
||||
activeProjectKey,
|
||||
scope,
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
hostScopeOptions,
|
||||
agents,
|
||||
sort,
|
||||
group,
|
||||
@@ -108,7 +109,7 @@ export function AiVaultPanelHeader({
|
||||
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
|
||||
<VaultHostScopeMenu
|
||||
executionHostScope={executionHostScope}
|
||||
activeSshExecutionHostScope={activeSshExecutionHostScope}
|
||||
hostOptions={hostScopeOptions}
|
||||
onExecutionHostScopeChange={onExecutionHostScopeChange}
|
||||
/>
|
||||
<VaultViewMenu
|
||||
|
||||
@@ -247,6 +247,25 @@ describe('SourceControlAgentActionDialog', () => {
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps prompt editing available when async auto-start delivery fails', async () => {
|
||||
mocks.onStart.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
return false
|
||||
})
|
||||
renderControlledDialog({
|
||||
baseCommandInput: 'Resolve queued comments.',
|
||||
savedCommandInputTemplate: '{basePrompt}'
|
||||
})
|
||||
expect(container.textContent).not.toContain('Launch agent')
|
||||
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
|
||||
|
||||
expect(container.querySelector('textarea')?.value).toContain('{basePrompt}')
|
||||
expect(container.textContent).toContain('Start agent')
|
||||
expect(mocks.onLaunched).not.toHaveBeenCalled()
|
||||
expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not auto-start when a saved receipt appears after the dialog is already open', async () => {
|
||||
let setSavedAgentId: (agent: TuiAgent | null) => void = () => {}
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
|
||||
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
|
||||
import {
|
||||
buildAiVaultHostScopeOptions,
|
||||
buildRuntimeAiVaultHostScopeOptions,
|
||||
useAiVaultExecutionHostScope
|
||||
} from './ai-vault-host-scope'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
|
||||
type HostScopeResult = ReturnType<typeof useAiVaultExecutionHostScope>
|
||||
|
||||
@@ -15,6 +20,7 @@ let latest: HostScopeResult | null = null
|
||||
function HookProbe(props: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
availableExecutionHostScopes?: readonly ExecutionHostScope[]
|
||||
}): null {
|
||||
latest = useAiVaultExecutionHostScope(props)
|
||||
return null
|
||||
@@ -23,6 +29,7 @@ function HookProbe(props: {
|
||||
async function renderHook(props: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
availableExecutionHostScopes?: readonly ExecutionHostScope[]
|
||||
}): Promise<void> {
|
||||
if (!root) {
|
||||
const container = document.createElement('div')
|
||||
@@ -83,7 +90,7 @@ describe('useAiVaultExecutionHostScope', () => {
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('ssh:dev-box')
|
||||
expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box')
|
||||
expect(latest?.activeExecutionHostScope).toBe('ssh:dev-box')
|
||||
})
|
||||
|
||||
it('defaults local worktrees to local history', async () => {
|
||||
@@ -96,7 +103,22 @@ describe('useAiVaultExecutionHostScope', () => {
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('local')
|
||||
expect(latest?.activeSshExecutionHostScope).toBeNull()
|
||||
expect(latest?.activeExecutionHostScope).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults runtime worktrees to their runtime execution host', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/runtime/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/runtime/repo',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: 'runtime:remote-server'
|
||||
}),
|
||||
availableExecutionHostScopes: ['runtime:remote-server'] as const
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('runtime:remote-server')
|
||||
expect(latest?.activeExecutionHostScope).toBe('runtime:remote-server')
|
||||
})
|
||||
|
||||
it('preserves manual host scope changes across unrelated rerenders', async () => {
|
||||
@@ -117,4 +139,56 @@ describe('useAiVaultExecutionHostScope', () => {
|
||||
|
||||
expect(latest?.executionHostScope).toBe('all')
|
||||
})
|
||||
|
||||
it('preserves manual runtime host scope choices while they are still available', async () => {
|
||||
const props = {
|
||||
activeWorktreeId: 'repo-1::/local/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/local/repo',
|
||||
repoId: 'repo-1'
|
||||
}),
|
||||
availableExecutionHostScopes: ['runtime:remote-server'] as const
|
||||
}
|
||||
await renderHook(props)
|
||||
|
||||
await act(async () => {
|
||||
latest?.onExecutionHostScopeChange('runtime:remote-server')
|
||||
})
|
||||
await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } })
|
||||
|
||||
expect(latest?.executionHostScope).toBe('runtime:remote-server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAiVaultHostScopeOptions', () => {
|
||||
it('adds saved runtime hosts between active SSH and all hosts', () => {
|
||||
const runtimeHostOptions = buildRuntimeAiVaultHostScopeOptions([
|
||||
{ id: 'remote-server', name: 'VPS Orca Server' }
|
||||
])
|
||||
|
||||
expect(
|
||||
buildAiVaultHostScopeOptions({
|
||||
activeExecutionHostScope: 'ssh:dev-box',
|
||||
runtimeHostOptions
|
||||
})
|
||||
).toEqual([
|
||||
{ id: 'local', label: expect.any(String) },
|
||||
{ id: 'ssh:dev-box', label: 'dev-box' },
|
||||
{ id: 'runtime:remote-server', label: 'VPS Orca Server' },
|
||||
{ id: 'all', label: 'All hosts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a runtime active host visible even before the saved environment list hydrates', () => {
|
||||
expect(
|
||||
buildAiVaultHostScopeOptions({
|
||||
activeExecutionHostScope: 'runtime:remote-server',
|
||||
runtimeHostOptions: []
|
||||
})
|
||||
).toEqual([
|
||||
{ id: 'local', label: expect.any(String) },
|
||||
{ id: 'runtime:remote-server', label: 'remote-server' },
|
||||
{ id: 'all', label: 'All hosts' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
getExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
|
||||
|
||||
export type AiVaultHostScopeOption = {
|
||||
id: ExecutionHostScope
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useAiVaultExecutionHostScope(args: {
|
||||
activeWorktreeId: string | null
|
||||
resumeTargetState: AiVaultSessionResumeTargetState
|
||||
availableExecutionHostScopes?: readonly ExecutionHostScope[]
|
||||
}): {
|
||||
executionHostScope: ExecutionHostScope
|
||||
activeSshExecutionHostScope: ExecutionHostScope | null
|
||||
activeExecutionHostScope: ExecutionHostId | null
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
} {
|
||||
const userChangedHostScopeRef = useRef(false)
|
||||
@@ -22,10 +32,12 @@ export function useAiVaultExecutionHostScope(args: {
|
||||
[args.activeWorktreeId, args.resumeTargetState]
|
||||
)
|
||||
const activeExecutionHost = parseExecutionHostId(activeExecutionHostId)
|
||||
const activeExecutionHostScope: ExecutionHostId | null =
|
||||
activeExecutionHost?.kind === 'ssh' || activeExecutionHost?.kind === 'runtime'
|
||||
? activeExecutionHost.id
|
||||
: null
|
||||
const defaultExecutionHostScope: ExecutionHostScope =
|
||||
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID
|
||||
const activeSshExecutionHostScope: ExecutionHostScope | null =
|
||||
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null
|
||||
activeExecutionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
const [executionHostScope, setExecutionHostScope] =
|
||||
useState<ExecutionHostScope>(defaultExecutionHostScope)
|
||||
|
||||
@@ -36,7 +48,8 @@ export function useAiVaultExecutionHostScope(args: {
|
||||
const allowedScopes = new Set<ExecutionHostScope>([
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : [])
|
||||
...(activeExecutionHostScope ? [activeExecutionHostScope] : []),
|
||||
...(args.availableExecutionHostScopes ?? [])
|
||||
])
|
||||
if (!allowedScopes.has(executionHostScope)) {
|
||||
setExecutionHostScope(defaultExecutionHostScope)
|
||||
@@ -46,7 +59,12 @@ export function useAiVaultExecutionHostScope(args: {
|
||||
if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) {
|
||||
setExecutionHostScope(defaultExecutionHostScope)
|
||||
}
|
||||
}, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope])
|
||||
}, [
|
||||
activeExecutionHostScope,
|
||||
args.availableExecutionHostScopes,
|
||||
defaultExecutionHostScope,
|
||||
executionHostScope
|
||||
])
|
||||
|
||||
const handleExecutionHostScopeChange = useCallback(
|
||||
(nextScope: ExecutionHostScope) => {
|
||||
@@ -58,7 +76,49 @@ export function useAiVaultExecutionHostScope(args: {
|
||||
|
||||
return {
|
||||
executionHostScope,
|
||||
activeSshExecutionHostScope,
|
||||
activeExecutionHostScope,
|
||||
onExecutionHostScopeChange: handleExecutionHostScopeChange
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeAiVaultHostScopeOptions(
|
||||
runtimeEnvironments: readonly Pick<PublicKnownRuntimeEnvironment, 'id' | 'name'>[]
|
||||
): AiVaultHostScopeOption[] {
|
||||
return runtimeEnvironments.map((environment) => {
|
||||
const id = toRuntimeExecutionHostId(environment.id)
|
||||
const label = environment.name.trim() || getExecutionHostLabel(id)
|
||||
return { id, label }
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAiVaultHostScopeOptions(args: {
|
||||
activeExecutionHostScope: ExecutionHostId | null
|
||||
runtimeHostOptions: readonly AiVaultHostScopeOption[]
|
||||
}): AiVaultHostScopeOption[] {
|
||||
const options: AiVaultHostScopeOption[] = []
|
||||
const seen = new Set<ExecutionHostScope>()
|
||||
const add = (option: AiVaultHostScopeOption): void => {
|
||||
if (seen.has(option.id)) {
|
||||
return
|
||||
}
|
||||
seen.add(option.id)
|
||||
options.push(option)
|
||||
}
|
||||
const activeHost = args.activeExecutionHostScope
|
||||
? parseExecutionHostId(args.activeExecutionHostScope)
|
||||
: null
|
||||
|
||||
add({ id: LOCAL_EXECUTION_HOST_ID, label: getExecutionHostLabel(LOCAL_EXECUTION_HOST_ID) })
|
||||
if (activeHost?.kind === 'ssh') {
|
||||
add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) })
|
||||
}
|
||||
for (const option of args.runtimeHostOptions) {
|
||||
add(option)
|
||||
}
|
||||
if (activeHost?.kind === 'runtime') {
|
||||
add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) })
|
||||
}
|
||||
add({ id: ALL_EXECUTION_HOSTS_SCOPE, label: getExecutionHostLabel(ALL_EXECUTION_HOSTS_SCOPE) })
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -100,21 +100,40 @@ export function useAiVaultSessionLaunchActions({
|
||||
return
|
||||
}
|
||||
|
||||
launchAiVaultSessionInNewTab({
|
||||
const launchResult = launchAiVaultSessionInNewTab({
|
||||
agent: session.agent,
|
||||
worktreeId: targetId.worktreeId,
|
||||
...buildResumeStartup(session, targetId.worktreeId)
|
||||
})
|
||||
const showQueuedToast = (): void => {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
|
||||
'{{value0}} session queued',
|
||||
{ value0: agentLabel(session.agent) }
|
||||
)
|
||||
)
|
||||
}
|
||||
if (launchResult.tabId === null) {
|
||||
void launchResult.runtimeLaunch.then((created) => {
|
||||
if (!created) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
|
||||
'Could not launch {{value0}} in a new terminal.',
|
||||
{ value0: agentLabel(session.agent) }
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
showQueuedToast()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) {
|
||||
activateAiVaultResumeWorkspace(targetId.worktreeId)
|
||||
}
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
|
||||
'{{value0}} session queued',
|
||||
{ value0: agentLabel(session.agent) }
|
||||
)
|
||||
)
|
||||
showQueuedToast()
|
||||
},
|
||||
[activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState]
|
||||
)
|
||||
@@ -167,15 +186,9 @@ export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
function aiVaultResumeUnsupportedMessage(
|
||||
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
|
||||
): string {
|
||||
if (targetStatus === 'runtime') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
}
|
||||
// Why: local and SSH targets can both be valid generally; this branch means
|
||||
// the session's recorded host does not match the selected workspace.
|
||||
if (targetStatus === 'ssh' || targetStatus === 'local') {
|
||||
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
|
||||
'This session belongs to a different host. Open a workspace on the same host to resume it.'
|
||||
@@ -183,7 +196,7 @@ function aiVaultResumeUnsupportedMessage(
|
||||
}
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
'Open a workspace before resuming a session.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -194,10 +194,33 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks runtime-owned targets even when they have no SSH connection', () => {
|
||||
it('allows runtime-owned targets for matching runtime sessions', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
repos: [
|
||||
makeRepo({
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
blocked: false,
|
||||
worktreeId: 'repo-1::/repo/orca',
|
||||
usesSessionWorktree: true
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks runtime-owned targets for sessions from another host', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
sessionExecutionHostId: 'local',
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -215,19 +238,20 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks runtime-stamped worktrees even when the repo owner is local', () => {
|
||||
it('allows runtime-stamped worktrees even when the repo owner is local', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree({ hostId: 'runtime:env-1' })],
|
||||
repos: [makeRepo({ connectionId: null, executionHostId: 'local' })]
|
||||
})
|
||||
).toEqual({
|
||||
blocked: true,
|
||||
worktreeId: null,
|
||||
usesSessionWorktree: false
|
||||
blocked: false,
|
||||
worktreeId: 'repo-1::/repo/orca',
|
||||
usesSessionWorktree: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,10 +343,11 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks an active runtime folder workspace', () => {
|
||||
it('allows an active runtime folder workspace for matching runtime sessions', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -330,8 +355,8 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
|
||||
})
|
||||
).toEqual({
|
||||
blocked: true,
|
||||
worktreeId: null,
|
||||
blocked: false,
|
||||
worktreeId: folderWorkspaceKey('folder-1'),
|
||||
usesSessionWorktree: false
|
||||
})
|
||||
})
|
||||
@@ -461,10 +486,11 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the active folder workspace action when it is runtime-owned', () => {
|
||||
it('enables the active folder workspace action when it is a matching runtime host', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -473,7 +499,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
})
|
||||
).toEqual({
|
||||
worktree: { worktreeId: null, disabled: true },
|
||||
newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: true }
|
||||
newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: false }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -505,10 +531,25 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume into a runtime folder workspace', () => {
|
||||
it('allows direct resume into a matching runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
|
||||
})
|
||||
).toEqual({
|
||||
status: 'ready',
|
||||
worktreeId: folderWorkspaceKey('folder-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume into a mismatched runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
sessionExecutionHostId: 'runtime:env-2',
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
@@ -326,6 +326,18 @@ describe('PRCommentsList comment resolution selection', () => {
|
||||
expect(container.querySelector('button[role="checkbox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps queued comments selected when clearRequest is null', () => {
|
||||
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
|
||||
renderList({ comments })
|
||||
clickButton('Queue for agent')
|
||||
|
||||
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
|
||||
|
||||
renderList({ comments, clearRequest: null })
|
||||
|
||||
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a clear request pending until its review context is mounted', () => {
|
||||
const queuedComments = [
|
||||
comment({
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
focusTerminalTabSurface: vi.fn(),
|
||||
launchAgentInNewTab: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onLaunched: vi.fn(),
|
||||
onSaveAgentDefault: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: mocks.focusTerminalTabSurface
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
|
||||
launchAgentInNewTab: mocks.launchAgentInNewTab
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: mocks.toastError }
|
||||
}))
|
||||
|
||||
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
|
||||
|
||||
function buildArgs(
|
||||
overrides: Partial<Parameters<typeof runSourceControlAgentActionStart>[0]> = {}
|
||||
): Parameters<typeof runSourceControlAgentActionStart>[0] {
|
||||
return {
|
||||
selectedAgent: 'codex',
|
||||
trimmedCommandInput: 'Fix the bug',
|
||||
agentArgs: '--model gpt-5',
|
||||
commandTemplate: '{basePrompt}',
|
||||
saveTargetValue: 'none',
|
||||
actionId: 'resolveComments',
|
||||
repoId: null,
|
||||
settings: null,
|
||||
repo: null,
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchPlatform: 'linux',
|
||||
launchSource: 'source_control_recovery',
|
||||
onStart: undefined,
|
||||
onSaveAgentDefault: mocks.onSaveAgentDefault,
|
||||
onLaunched: mocks.onLaunched,
|
||||
onClose: mocks.onClose,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('runSourceControlAgentActionStart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('waits for deferred prompt delivery before confirming a source-control launch', async () => {
|
||||
mocks.launchAgentInNewTab.mockReturnValue({
|
||||
tabId: 'tab-1',
|
||||
startupPlan: {} as never,
|
||||
pasteDraftAfterLaunch: true,
|
||||
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
|
||||
})
|
||||
|
||||
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(true)
|
||||
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
|
||||
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the source-control dialog open when deferred prompt delivery fails', async () => {
|
||||
mocks.launchAgentInNewTab.mockReturnValue({
|
||||
tabId: 'tab-1',
|
||||
startupPlan: {} as never,
|
||||
pasteDraftAfterLaunch: true,
|
||||
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false })
|
||||
})
|
||||
|
||||
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
|
||||
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
|
||||
expect(mocks.onLaunched).not.toHaveBeenCalled()
|
||||
expect(mocks.onClose).not.toHaveBeenCalled()
|
||||
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.')
|
||||
})
|
||||
|
||||
it('does not show a generic start failure when deferred delivery already notified the user', async () => {
|
||||
mocks.launchAgentInNewTab.mockReturnValue({
|
||||
tabId: 'tab-1',
|
||||
startupPlan: {} as never,
|
||||
pasteDraftAfterLaunch: true,
|
||||
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true })
|
||||
})
|
||||
|
||||
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
|
||||
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
|
||||
expect(mocks.onLaunched).not.toHaveBeenCalled()
|
||||
expect(mocks.onClose).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs and treats a rejected promptDeliveryResult as a launch failure', async () => {
|
||||
const error = new Error('boom')
|
||||
const originalConsole = console
|
||||
const consoleError = vi.fn()
|
||||
vi.stubGlobal('console', { ...originalConsole, error: consoleError })
|
||||
mocks.launchAgentInNewTab.mockReturnValue({
|
||||
tabId: 'tab-1',
|
||||
startupPlan: {} as never,
|
||||
pasteDraftAfterLaunch: true,
|
||||
promptDeliveryResult: Promise.reject(error)
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
|
||||
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
|
||||
expect(consoleError).toHaveBeenCalledWith('promptDeliveryResult rejected', error)
|
||||
expect(mocks.onLaunched).not.toHaveBeenCalled()
|
||||
expect(mocks.onClose).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.')
|
||||
} finally {
|
||||
vi.stubGlobal('console', originalConsole)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps non-deferred tab launches immediate', async () => {
|
||||
mocks.launchAgentInNewTab.mockReturnValue({
|
||||
tabId: 'tab-1',
|
||||
startupPlan: {} as never,
|
||||
pasteDraftAfterLaunch: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
runSourceControlAgentActionStart(buildArgs({ promptDelivery: 'draft' }))
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
|
||||
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps injected onStart successes immediate', async () => {
|
||||
const onStart = vi.fn().mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
runSourceControlAgentActionStart(
|
||||
buildArgs({
|
||||
onStart,
|
||||
worktreeId: undefined,
|
||||
groupId: undefined
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(onStart).toHaveBeenCalledWith({
|
||||
agent: 'codex',
|
||||
commandInput: 'Fix the bug',
|
||||
agentArgs: '--model gpt-5'
|
||||
})
|
||||
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
|
||||
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,7 @@ export async function runSourceControlAgentActionStart({
|
||||
onClose
|
||||
}: RunSourceControlAgentActionStartArgs): Promise<boolean> {
|
||||
let launched = false
|
||||
let launchFailureNotified = false
|
||||
if (onStart) {
|
||||
launched = await onStart({
|
||||
agent: selectedAgent,
|
||||
@@ -83,14 +84,26 @@ export async function runSourceControlAgentActionStart({
|
||||
if (result?.tabId) {
|
||||
focusTerminalTabSurface(result.tabId)
|
||||
}
|
||||
if (result?.promptDeliveryResult) {
|
||||
try {
|
||||
const deliveryResult = await result.promptDeliveryResult
|
||||
launched = deliveryResult.delivered
|
||||
launchFailureNotified = deliveryResult.failureNotified
|
||||
} catch (error) {
|
||||
console.error('promptDeliveryResult rejected', error)
|
||||
launched = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!launched) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1',
|
||||
'Could not start the selected agent.'
|
||||
if (!launchFailureNotified) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1',
|
||||
'Could not start the selected agent.'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -173,20 +173,11 @@ export default function AiVaultSessionDropLayer({
|
||||
const state = useAppStore.getState()
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId)
|
||||
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId)
|
||||
if (targetStatus === 'runtime') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (targetStatus === 'unknown') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
'Open a workspace before resuming a session.'
|
||||
)
|
||||
)
|
||||
return true
|
||||
@@ -208,7 +199,7 @@ export default function AiVaultSessionDropLayer({
|
||||
return true
|
||||
}
|
||||
|
||||
launchAiVaultSessionInNewTab({
|
||||
const launchResult = launchAiVaultSessionInNewTab({
|
||||
agent: payload.agent,
|
||||
worktreeId,
|
||||
command: payload.command,
|
||||
@@ -217,12 +208,31 @@ export default function AiVaultSessionDropLayer({
|
||||
targetGroupId: dropTarget.groupId,
|
||||
splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone
|
||||
})
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued',
|
||||
'Session queued'
|
||||
const showQueuedToast = (): void => {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued',
|
||||
'Session queued'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (launchResult.tabId === null) {
|
||||
void launchResult.runtimeLaunch.then((created) => {
|
||||
if (!created) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
|
||||
'Could not launch {{value0}} in a new terminal.',
|
||||
{ value0: payload.agent }
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
showQueuedToast()
|
||||
})
|
||||
return true
|
||||
}
|
||||
showQueuedToast()
|
||||
return true
|
||||
},
|
||||
[clearDragState, target, updateTarget, worktreeId]
|
||||
|
||||
@@ -24,7 +24,17 @@ import TerminalSearch from '@/components/TerminalSearch'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { fitPanes, isWindowsUserAgent } from './pane-helpers'
|
||||
import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
selectRuntimeAwareSshStatus,
|
||||
selectRuntimeAwareSshTargetLabel,
|
||||
selectRuntimeAwareSshTargetRemoved
|
||||
} from '@/store/slices/runtime-environment-ssh'
|
||||
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import {
|
||||
@@ -306,26 +316,44 @@ export default function TerminalPane({
|
||||
}
|
||||
return connectionId
|
||||
})
|
||||
// Which machine's SSH store this target belongs to: a remote Orca server's
|
||||
// per-environment bucket, or null for this machine's local SSH maps. The
|
||||
// explicit-owner resolver never lets a merely focused runtime make a
|
||||
// local-owned workspace look remote. The paired web client mirrors its one
|
||||
// host through the local maps instead.
|
||||
const sshReconnectEnvironmentId = useAppStore((store) =>
|
||||
sshReconnectTargetId && !isPairedWebClientWindow()
|
||||
? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId)
|
||||
: null
|
||||
)
|
||||
const sshReconnectStatus = useAppStore((store) =>
|
||||
sshReconnectTargetId
|
||||
? (store.sshConnectionStates.get(sshReconnectTargetId)?.status ?? 'disconnected')
|
||||
? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId)
|
||||
: null
|
||||
)
|
||||
const sshReconnectTargetLabel = useAppStore((store) =>
|
||||
sshReconnectTargetId
|
||||
? (store.sshTargetLabels.get(sshReconnectTargetId) ??
|
||||
// Fall back to the removed target's last known label (ghost host) before
|
||||
// the raw id, so a removed host shows its name instead of ssh-<ts>-<rand>.
|
||||
store.removedSshTargetLabels.get(sshReconnectTargetId) ??
|
||||
sshReconnectTargetId)
|
||||
? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId)
|
||||
: ''
|
||||
)
|
||||
// Why: the target was removed entirely (a ghost) when it's no longer a known
|
||||
// SSH target. Reconnecting to it can only fail ("SSH target not found"), so
|
||||
// the overlay must offer to remove the workspace instead of Connect.
|
||||
// SSH target on its owning host. Reconnecting to it can only fail ("SSH
|
||||
// target not found"), so the overlay must offer to remove the workspace
|
||||
// instead of Connect. The selector requires positive removal evidence.
|
||||
const sshReconnectTargetRemoved = useAppStore((store) =>
|
||||
sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false
|
||||
sshReconnectTargetId
|
||||
? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId)
|
||||
: false
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!sshReconnectEnvironmentId) {
|
||||
return
|
||||
}
|
||||
// Why: an SSH-backed workspace can be mirrored before its owning
|
||||
// environment's bucket ever hydrated (no-op once hydrated), and overlay
|
||||
// state must come from fetched evidence, never from an empty default.
|
||||
void hydrateRuntimeEnvironmentSshState(sshReconnectEnvironmentId).catch(() => {})
|
||||
}, [sshReconnectEnvironmentId])
|
||||
|
||||
useVisibleTerminalTabClaim({ isVisible, tabId })
|
||||
|
||||
@@ -2984,6 +3012,7 @@ export default function TerminalPane({
|
||||
status={sshReconnectStatus}
|
||||
targetRemoved={sshReconnectTargetRemoved}
|
||||
worktreeId={worktreeId}
|
||||
sshOwnerEnvironmentId={sshReconnectEnvironmentId}
|
||||
/>
|
||||
) : null}
|
||||
<DaemonActionDialog api={daemonActions} />
|
||||
|
||||
@@ -17,6 +17,11 @@ const deleteFlowMocks = vi.hoisted(() => ({
|
||||
runWorktreeDelete: vi.fn()
|
||||
}))
|
||||
|
||||
const environmentSshMocks = vi.hoisted(() => ({
|
||||
connectRuntimeEnvironmentSshTarget: vi.fn(),
|
||||
resyncRuntimeEnvironmentSshTargets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: toastMocks.error
|
||||
@@ -27,17 +32,25 @@ vi.mock('../sidebar/delete-worktree-flow', () => ({
|
||||
runWorktreeDelete: deleteFlowMocks.runWorktreeDelete
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks)
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', values?.value0 ?? '')
|
||||
}))
|
||||
|
||||
function installSshConnect(connect: ReturnType<typeof vi.fn>): void {
|
||||
function installSshConnect(
|
||||
connect: ReturnType<typeof vi.fn>,
|
||||
overrides: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
): void {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ssh: {
|
||||
connect
|
||||
connect,
|
||||
listTargets: vi.fn().mockResolvedValue([]),
|
||||
listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -48,6 +61,8 @@ describe('TerminalSshReconnectOverlay', () => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
toastMocks.error.mockReset()
|
||||
deleteFlowMocks.runWorktreeDelete.mockReset()
|
||||
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
|
||||
environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,6 +125,56 @@ describe('TerminalSshReconnectOverlay', () => {
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('resyncs target metadata after a failed connect so a stale overlay converges', async () => {
|
||||
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
|
||||
const listTargets = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
|
||||
])
|
||||
const listRemovedTargetLabels = vi.fn().mockResolvedValue({ 'ssh-dead': 'devbox (removed)' })
|
||||
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
|
||||
// Why: the metadata refresh is what flips TerminalPane's targetRemoved
|
||||
// derivation, replacing the failing Connect loop with the ghost-host UI.
|
||||
await waitFor(() => {
|
||||
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
|
||||
expect(useAppStore.getState().removedSshTargetLabels.get('ssh-dead')).toBe('devbox (removed)')
|
||||
})
|
||||
})
|
||||
|
||||
it('still applies the target list when the removed-labels refresh fails', async () => {
|
||||
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
|
||||
const listTargets = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
|
||||
])
|
||||
const listRemovedTargetLabels = vi.fn().mockRejectedValue(new Error('unavailable'))
|
||||
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
|
||||
// Why: a removed-labels failure must not discard the refreshed target
|
||||
// list — it alone is enough evidence for targetRemoved to converge.
|
||||
await waitFor(() => {
|
||||
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
|
||||
expect(useAppStore.getState().sshTargetsHydrated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined)
|
||||
installSshConnect(connect)
|
||||
@@ -134,6 +199,65 @@ describe('TerminalSshReconnectOverlay', () => {
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes Connect to the owning environment runtime RPC for a remote-owned workspace', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined)
|
||||
installSshConnect(connect)
|
||||
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null)
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TerminalSshReconnectOverlay
|
||||
targetId="ssh-remote-1"
|
||||
targetLabel="devbox"
|
||||
status="disconnected"
|
||||
sshOwnerEnvironmentId="env-1"
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith(
|
||||
'env-1',
|
||||
'ssh-remote-1'
|
||||
)
|
||||
)
|
||||
// The local ssh API must never see a remote host's target.
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resyncs the owning environment (not the local store) after a failed remote connect', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined)
|
||||
const listTargets = vi.fn().mockResolvedValue([])
|
||||
installSshConnect(connect, { listTargets })
|
||||
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockRejectedValue(
|
||||
new Error('SSH target "ssh-remote-dead" not found')
|
||||
)
|
||||
environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TerminalSshReconnectOverlay
|
||||
targetId="ssh-remote-dead"
|
||||
targetLabel="devbox"
|
||||
status="disconnected"
|
||||
sshOwnerEnvironmentId="env-1"
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-remote-dead" not found')
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(environmentSshMocks.resyncRuntimeEnvironmentSshTargets).toHaveBeenCalledWith('env-1')
|
||||
)
|
||||
// The failed-connect resync must not rewrite local target metadata.
|
||||
expect(listTargets).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().sshTargetsHydrated).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes the returned SSH state so deferred terminal reattach can resume', async () => {
|
||||
const connectedState: SshConnectionState = {
|
||||
targetId: 'ssh-target-1',
|
||||
|
||||
@@ -7,6 +7,10 @@ import { useAppStore } from '@/store'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
|
||||
import {
|
||||
connectRuntimeEnvironmentSshTarget,
|
||||
resyncRuntimeEnvironmentSshTargets
|
||||
} from '@/runtime/runtime-environment-ssh-state'
|
||||
|
||||
type TerminalSshReconnectOverlayProps = {
|
||||
targetId: string
|
||||
@@ -16,6 +20,10 @@ type TerminalSshReconnectOverlayProps = {
|
||||
// remove the workspace instead of a Connect button that can only fail.
|
||||
targetRemoved?: boolean
|
||||
worktreeId?: string
|
||||
// Set when the SSH target belongs to a remote Orca server (runtime
|
||||
// environment): Connect and the failed-connect resync then route to that
|
||||
// environment's runtime RPC and bucket instead of the local ssh.* API.
|
||||
sshOwnerEnvironmentId?: string | null
|
||||
}
|
||||
|
||||
// Why: relay deployment/reconnect are host-driven transient states; the
|
||||
@@ -70,7 +78,8 @@ export function TerminalSshReconnectOverlay({
|
||||
targetLabel,
|
||||
status,
|
||||
targetRemoved = false,
|
||||
worktreeId
|
||||
worktreeId,
|
||||
sshOwnerEnvironmentId = null
|
||||
}: TerminalSshReconnectOverlayProps): React.JSX.Element {
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
@@ -85,11 +94,16 @@ export function TerminalSshReconnectOverlay({
|
||||
}
|
||||
setConnecting(true)
|
||||
try {
|
||||
const connectState = await window.api.ssh.connect({ targetId })
|
||||
if (connectState) {
|
||||
// Why: ssh.connect can resolve before the global state-change IPC lands;
|
||||
// the waiting deferred PTY reattach path keys off this renderer store.
|
||||
setSshConnectionState(targetId, connectState)
|
||||
if (sshOwnerEnvironmentId) {
|
||||
// Bucket state is written inside the helper, mirroring the local path.
|
||||
await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
|
||||
} else {
|
||||
const connectState = await window.api.ssh.connect({ targetId })
|
||||
if (connectState) {
|
||||
// Why: ssh.connect can resolve before the global state-change IPC lands;
|
||||
// the waiting deferred PTY reattach path keys off this renderer store.
|
||||
setSshConnectionState(targetId, connectState)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
@@ -100,12 +114,27 @@ export function TerminalSshReconnectOverlay({
|
||||
'SSH connection failed'
|
||||
)
|
||||
)
|
||||
// Why: a failed connect usually means the renderer's target metadata is
|
||||
// stale (target removed, or re-added under a new id). Resync it so the
|
||||
// overlay converges to the ghost/re-adopted state instead of offering
|
||||
// the same failing Connect forever (STA-1468). Apply the target list
|
||||
// first — a removed-labels failure must not discard it.
|
||||
if (sshOwnerEnvironmentId) {
|
||||
void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {})
|
||||
} else {
|
||||
void (async () => {
|
||||
const targets = await window.api.ssh.listTargets()
|
||||
useAppStore.getState().setSshTargetsMetadata(targets)
|
||||
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
|
||||
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
|
||||
})().catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
}, [isConnecting, mountedRef, setSshConnectionState, targetId])
|
||||
}, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -215,6 +215,115 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('re-derives the host session handle after a transport close instead of resubscribing the stale one', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onPtySpawn = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-tab-1',
|
||||
leafId: 'pane:1',
|
||||
onPtySpawn
|
||||
})
|
||||
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:env-1@@terminal-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-1' })
|
||||
|
||||
// Why: while the tunnel was down the host re-minted this pane's handle;
|
||||
// resubscribing the stale closure handle would bind the mirror to a
|
||||
// different PTY (#7718). The transport must re-derive from the snapshot.
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'session.tabs.list'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-2'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
const subscribeCallsBefore = runtimeSubscribe.mock.calls.length
|
||||
|
||||
// The dedicated multiplex socket dies (liveness/close) → onTransportClose.
|
||||
subscriptionCallbacks?.onClose?.()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimeSubscribe.mock.calls.length).toBeGreaterThan(subscribeCallsBefore)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' })
|
||||
)
|
||||
expect(transport.getPtyId()).toContain('terminal-2')
|
||||
expect(onPtySpawn).toHaveBeenCalledWith(expect.stringContaining('terminal-2'))
|
||||
})
|
||||
|
||||
it('retires the mirror when the host no longer publishes the surface after a transport close', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onPtyExit = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-tab-1',
|
||||
leafId: 'pane:1',
|
||||
onPtyExit
|
||||
})
|
||||
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:env-1@@terminal-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: { onError }
|
||||
})
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'session.tabs.list'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
|
||||
subscriptionCallbacks?.onClose?.()
|
||||
|
||||
// Why: no red xterm error — retire quietly and let the next session-tabs
|
||||
// snapshot drive respawn/removal.
|
||||
await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1'))
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close host-owned terminal handles attached from session snapshots', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
|
||||
@@ -182,6 +182,16 @@ export function createRemoteRuntimePtyTransport(
|
||||
return null
|
||||
}
|
||||
|
||||
async function listHostSessionHandle(hostTabId: string): Promise<string | null> {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const listed = await callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId)
|
||||
})
|
||||
return findReadyHostSessionHandle(listed, hostTabId)
|
||||
}
|
||||
|
||||
async function attachHostSessionMirror(
|
||||
options: Parameters<PtyTransport['connect']>[0]
|
||||
): Promise<PtyConnectResult | undefined> {
|
||||
@@ -280,7 +290,9 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
|
||||
// Why: stale-handle errors must retire the mirror (recoverable via the
|
||||
// next snapshot) rather than dead-end in a red xterm banner (#7718).
|
||||
handleRemoteTerminalError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -298,7 +310,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
text,
|
||||
client: { id: clientId, type: 'desktop' }
|
||||
}).catch((error) => {
|
||||
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
|
||||
handleRemoteTerminalError(error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -371,6 +383,31 @@ export function createRemoteRuntimePtyTransport(
|
||||
storedCallbacks.onError?.(message)
|
||||
}
|
||||
|
||||
// Why: after a transport drop the host may have re-minted this pane's
|
||||
// handle (reconnect, epoch or PTY change). Re-derive it from the current
|
||||
// session snapshot instead of resubscribing the stale closure value, which
|
||||
// would mirror (and type into) whatever PTY now sits behind it (#7718).
|
||||
async function resubscribeAfterTransportClose(previousHandle: string): Promise<void> {
|
||||
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
|
||||
const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId))
|
||||
if (destroyed || !connected || handle !== previousHandle) {
|
||||
return
|
||||
}
|
||||
if (!nextHandle) {
|
||||
// Why: the host no longer publishes this surface; retire quietly and
|
||||
// let the next session-tabs snapshot drive respawn/removal.
|
||||
retireRemoteTerminalId()
|
||||
return
|
||||
}
|
||||
if (nextHandle !== previousHandle) {
|
||||
handle = nextHandle
|
||||
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
|
||||
onPtySpawn?.(remotePtyId)
|
||||
}
|
||||
}
|
||||
await subscribeToHandle()
|
||||
}
|
||||
|
||||
async function subscribeToHandle(): Promise<void> {
|
||||
if (!handle) {
|
||||
return
|
||||
@@ -453,10 +490,9 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
resubscribing = true
|
||||
const resubscribeHandle = handle
|
||||
const resubscribePtyId = remotePtyId
|
||||
void subscribeToHandle()
|
||||
void resubscribeAfterTransportClose(resubscribeHandle)
|
||||
.catch((error) => {
|
||||
if (isCurrentRemoteTerminal(resubscribeHandle, resubscribePtyId)) {
|
||||
if (!destroyed && connected && handle) {
|
||||
handleRemoteTerminalError(error)
|
||||
}
|
||||
})
|
||||
@@ -659,7 +695,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
text,
|
||||
client: { id: clientId, type: 'desktop' }
|
||||
}).catch((error) => {
|
||||
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
|
||||
handleRemoteTerminalError(error)
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildRuntimeClientEventEnvironmentKey,
|
||||
buildNewWorkspaceShortcutModalData,
|
||||
getNewlyConnectedRuntimeEnvironmentIds,
|
||||
getNewlyDisconnectedRuntimeEnvironmentIds,
|
||||
getRuntimeProjectRefreshEnvironmentIds,
|
||||
openNewWorkspaceFromShortcut,
|
||||
resolveBrowserSessionTabTarget,
|
||||
@@ -57,6 +58,15 @@ describe('getNewlyConnectedRuntimeEnvironmentIds', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNewlyDisconnectedRuntimeEnvironmentIds', () => {
|
||||
it('returns only environments whose transport was just observed down', () => {
|
||||
expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a', 'env-b'], ['env-a'])).toEqual([
|
||||
'env-b'
|
||||
])
|
||||
expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRuntimeProjectRefreshEnvironmentIds', () => {
|
||||
it('refreshes when an already-desired runtime becomes reachable', () => {
|
||||
expect(
|
||||
|
||||
@@ -83,6 +83,11 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
||||
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
|
||||
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
|
||||
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
|
||||
import {
|
||||
applyRuntimeEnvironmentSshStateChanged,
|
||||
hydrateRuntimeEnvironmentSshState
|
||||
} from '@/runtime/runtime-environment-ssh-state'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
|
||||
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
@@ -769,6 +774,15 @@ export function getNewlyConnectedRuntimeEnvironmentIds(
|
||||
return [...new Set(next)].filter((environmentId) => !known.has(environmentId))
|
||||
}
|
||||
|
||||
/** Ids in `previous` not in `next` — runtime environments whose transport was
|
||||
* just observed down. Their mirrored SSH buckets get downgraded to unknown. */
|
||||
export function getNewlyDisconnectedRuntimeEnvironmentIds(
|
||||
previous: readonly string[],
|
||||
next: readonly string[]
|
||||
): string[] {
|
||||
return getNewlyConnectedRuntimeEnvironmentIds(next, previous)
|
||||
}
|
||||
|
||||
export function getRuntimeProjectRefreshEnvironmentIds(args: {
|
||||
previousDesired: readonly string[]
|
||||
nextDesired: readonly string[]
|
||||
@@ -954,6 +968,13 @@ export function useIpcEvents(): void {
|
||||
|
||||
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh: async (environmentId) => {
|
||||
if (!isPairedWebClientWindow()) {
|
||||
// Why: mirrored SSH-backed workspaces read the owning environment's
|
||||
// SSH bucket; refresh it whenever the environment (re)connects so a
|
||||
// pre-drop snapshot can't keep a reconnect overlay stale. The web
|
||||
// client mirrors host SSH state through the global store instead.
|
||||
void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {})
|
||||
}
|
||||
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
|
||||
await refreshRuntimeProjectWorktrees(repos)
|
||||
await useAppStore.getState().fetchWorktreeLineage()
|
||||
@@ -963,11 +984,28 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Assigned later in this effect, next to the ssh.onStateChanged wiring;
|
||||
// events can't fire before that because subscriptions attach asynchronously.
|
||||
let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null =
|
||||
null
|
||||
|
||||
const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => {
|
||||
if (event.type === 'reposChanged') {
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
return
|
||||
}
|
||||
if (event.type === 'sshStateChanged') {
|
||||
// Why: a paired web client mirrors host SSH state in the global store —
|
||||
// its whole ssh.* API routes to that one host (STA-1468). A desktop
|
||||
// client owns a local SSH surface those maps must keep describing, so a
|
||||
// remote host's state goes into that environment's own bucket instead.
|
||||
if (isPairedWebClientWindow()) {
|
||||
handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state })
|
||||
} else {
|
||||
applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'worktreesChanged') {
|
||||
void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() =>
|
||||
worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId })
|
||||
@@ -992,7 +1030,17 @@ export function useIpcEvents(): void {
|
||||
|
||||
const runtimeClientEventsSync = createRuntimeClientEventsSync({
|
||||
getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds,
|
||||
subscribe: subscribeRuntimeClientEvents,
|
||||
subscribe: (environmentId, onEvent, onError) =>
|
||||
subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => {
|
||||
if (isPairedWebClientWindow()) {
|
||||
return
|
||||
}
|
||||
// Why: sshStateChanged events during the transport gap are lost, so
|
||||
// the pre-drop bucket may hold a stale "connected". Downgrade to
|
||||
// unknown, then refetch the authoritative state.
|
||||
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
|
||||
void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {})
|
||||
}),
|
||||
onEvent: handleRuntimeClientEvent
|
||||
})
|
||||
|
||||
@@ -1033,6 +1081,13 @@ export function useIpcEvents(): void {
|
||||
})) {
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
}
|
||||
for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds(
|
||||
reachableRuntimeEnvironmentIds,
|
||||
nextReachableEnvironmentIds
|
||||
)) {
|
||||
// No-op when the environment has no SSH bucket (e.g. web client).
|
||||
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
|
||||
}
|
||||
runtimeClientEventEnvironmentIds = nextEnvironmentIds
|
||||
runtimeClientEventEnvironmentKey = nextKey
|
||||
reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds
|
||||
@@ -2667,49 +2722,49 @@ export function useIpcEvents(): void {
|
||||
let sshTargetStateEventId = 0
|
||||
const latestSshTargetStateEventByTargetId = new Map<string, number>()
|
||||
|
||||
unsubs.push(
|
||||
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
|
||||
const store = useAppStore.getState()
|
||||
const state = data.state as SshConnectionState
|
||||
const stateEventId = ++sshTargetStateEventId
|
||||
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
|
||||
if (!store.sshTargetLabels.has(data.targetId)) {
|
||||
// Why: targets added after boot aren't in the labels map, while
|
||||
// removed targets can still race a final disconnect event. Confirm
|
||||
// with main before mutating renderer state for an unknown target id.
|
||||
window.api.ssh
|
||||
.listTargets()
|
||||
// Why: this refresh is now a deletion guard, not just a label fetch.
|
||||
// Retry once so a transient IPC failure does not drop a real added-target event.
|
||||
.catch(() => window.api.ssh.listTargets())
|
||||
.then((targets) => {
|
||||
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
|
||||
return
|
||||
}
|
||||
handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => {
|
||||
const store = useAppStore.getState()
|
||||
const state = data.state as SshConnectionState
|
||||
const stateEventId = ++sshTargetStateEventId
|
||||
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
|
||||
if (!store.sshTargetLabels.has(data.targetId)) {
|
||||
// Why: targets added after boot aren't in the labels map, while
|
||||
// removed targets can still race a final disconnect event. Confirm
|
||||
// with main before mutating renderer state for an unknown target id.
|
||||
window.api.ssh
|
||||
.listTargets()
|
||||
// Why: this refresh is now a deletion guard, not just a label fetch.
|
||||
// Retry once so a transient IPC failure does not drop a real added-target event.
|
||||
.catch(() => window.api.ssh.listTargets())
|
||||
.then((targets) => {
|
||||
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
|
||||
return
|
||||
}
|
||||
latestSshTargetStateEventByTargetId.delete(data.targetId)
|
||||
const latestStore = useAppStore.getState()
|
||||
if (!targets.some((target) => target.id === data.targetId)) {
|
||||
// Why: disconnect/state events can race after target removal.
|
||||
// Treat absence from main's target list as deletion, not a new target.
|
||||
latestStore.clearRemovedSshTargetState(data.targetId)
|
||||
return
|
||||
}
|
||||
latestStore.setSshTargetsMetadata(targets)
|
||||
applySshConnectionStateChange(data.targetId, state)
|
||||
})
|
||||
.catch(() => {
|
||||
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
|
||||
latestSshTargetStateEventByTargetId.delete(data.targetId)
|
||||
const latestStore = useAppStore.getState()
|
||||
if (!targets.some((target) => target.id === data.targetId)) {
|
||||
// Why: disconnect/state events can race after target removal.
|
||||
// Treat absence from main's target list as deletion, not a new target.
|
||||
latestStore.clearRemovedSshTargetState(data.targetId)
|
||||
return
|
||||
}
|
||||
latestStore.setSshTargetsMetadata(targets)
|
||||
applySshConnectionStateChange(data.targetId, state)
|
||||
})
|
||||
.catch(() => {
|
||||
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
|
||||
latestSshTargetStateEventByTargetId.delete(data.targetId)
|
||||
applySshConnectionStateChange(data.targetId, state)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
latestSshTargetStateEventByTargetId.delete(data.targetId)
|
||||
applySshConnectionStateChange(data.targetId, state)
|
||||
})
|
||||
)
|
||||
latestSshTargetStateEventByTargetId.delete(data.targetId)
|
||||
applySshConnectionStateChange(data.targetId, state)
|
||||
}
|
||||
|
||||
unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent))
|
||||
|
||||
let remoteWorkspaceClientId: string | null = null
|
||||
let remoteWorkspaceClientIdPromise: Promise<string | null> | null = null
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"e3bcd082ac": "Connect to Orca",
|
||||
"mobileScopeRejected": "This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link."
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
"api": {
|
||||
@@ -2588,8 +2591,7 @@
|
||||
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
|
||||
"openLocalWorkspace": "Open a local workspace before resuming a session.",
|
||||
"sessionQueued": "Session queued",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
|
||||
},
|
||||
@@ -9648,9 +9650,7 @@
|
||||
"logPath": "Log path",
|
||||
"originalPaneUnavailable": "Original pane is no longer available.",
|
||||
"worktreeUnavailable": "Worktree is no longer available.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
},
|
||||
@@ -11729,7 +11729,7 @@
|
||||
"f2d0c22d67": "Delete annotation {{value0}}",
|
||||
"11c5084aa2": "Clear annotations",
|
||||
"734e4343ec": "Clear browser annotations",
|
||||
"95af781091": "Send feedback to a new agent",
|
||||
"95af781091": "Send feedback to an agent",
|
||||
"ac39b9366b": "Send",
|
||||
"a3508d7e6e": "{{value0}} annotation{{value1}} ready. Select another element or copy all feedback.",
|
||||
"f796c774a4": "Type a URL above to start browsing.",
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"e3bcd082ac": "Conectar con Orca",
|
||||
"mobileScopeRejected": "Este código QR solo da acceso limitado para móvil. Para usar la app web completa, abre el enlace para navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace."
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
"api": {
|
||||
@@ -2588,8 +2591,7 @@
|
||||
"localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.",
|
||||
"openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.",
|
||||
"sessionQueued": "Sesión en cola",
|
||||
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.",
|
||||
"openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.",
|
||||
"openSupportedWorkspace": "Abre un espacio de trabajo antes de reanudar una sesión.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
|
||||
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host."
|
||||
},
|
||||
@@ -9648,9 +9650,7 @@
|
||||
"sessionsShownCompact": "{{value0}} mostradas",
|
||||
"originalPaneUnavailable": "El panel original ya no está disponible.",
|
||||
"worktreeUnavailable": "El worktree ya no está disponible.",
|
||||
"runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.",
|
||||
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.",
|
||||
"openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.",
|
||||
"openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
|
||||
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla."
|
||||
},
|
||||
@@ -11729,7 +11729,7 @@
|
||||
"f2d0c22d67": "Eliminar anotación {{value0}}",
|
||||
"11c5084aa2": "Borrar anotaciones",
|
||||
"734e4343ec": "Borrar anotaciones del navegador",
|
||||
"95af781091": "Enviar comentarios a un nuevo agente",
|
||||
"95af781091": "Enviar comentarios a un agente",
|
||||
"ac39b9366b": "Enviar",
|
||||
"a3508d7e6e": "{{value0}} anotación{{value1}} listo. Seleccione otro elemento o copie todos los comentarios.",
|
||||
"f796c774a4": "Escriba una URL arriba para comenzar a navegar.",
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"e3bcd082ac": "Orca に接続",
|
||||
"mobileScopeRejected": "この QR コードは制限付き(モバイル)アクセスを許可します。完全な Web アプリを使うには、設定 → リモート Orca サーバー → この Orca サーバーを共有する → 新規リンク からブラウザー用アクセスリンクを開いてください。"
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
"api": {
|
||||
@@ -2588,8 +2591,7 @@
|
||||
"localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。",
|
||||
"openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。",
|
||||
"sessionQueued": "セッションをキューに追加しました",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。",
|
||||
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。"
|
||||
},
|
||||
@@ -9648,9 +9650,7 @@
|
||||
"sessionsShownCompact": "{{value0}} 件表示",
|
||||
"originalPaneUnavailable": "元のペインは利用できなくなりました。",
|
||||
"worktreeUnavailable": "ワークツリーは利用できなくなりました。",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。",
|
||||
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。"
|
||||
},
|
||||
@@ -11729,7 +11729,7 @@
|
||||
"f2d0c22d67": "注釈 {{value0}} を削除",
|
||||
"11c5084aa2": "注釈をクリアする",
|
||||
"734e4343ec": "ブラウザの注釈をクリアする",
|
||||
"95af781091": "新規 agent にフィードバックを送信する",
|
||||
"95af781091": "agent にフィードバックを送信する",
|
||||
"ac39b9366b": "送信",
|
||||
"a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。",
|
||||
"f796c774a4": "上に URL を入力して閲覧を開始します。",
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"e3bcd082ac": "Orca에 연결",
|
||||
"mobileScopeRejected": "이 QR 코드는 제한된(모바일) 액세스 권한만 제공합니다. 전체 웹 앱을 사용하려면 설정 → 원격 Orca 서버 → 이 Orca 서버 공유 → 새 링크에서 브라우저 액세스 링크를 여세요."
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
"api": {
|
||||
@@ -2588,8 +2591,7 @@
|
||||
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
|
||||
"openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.",
|
||||
"sessionQueued": "세션이 대기열에 추가됨",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.",
|
||||
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요."
|
||||
},
|
||||
@@ -9648,9 +9650,7 @@
|
||||
"sessionsShownCompact": "{{value0}} 표시됨",
|
||||
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
|
||||
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"openSupportedWorkspace": "Open a workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.",
|
||||
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요."
|
||||
},
|
||||
@@ -11729,7 +11729,7 @@
|
||||
"f2d0c22d67": "주석 {{value0}} 삭제",
|
||||
"11c5084aa2": "주석 지우기",
|
||||
"734e4343ec": "브라우저 주석 지우기",
|
||||
"95af781091": "새 agent에게 피드백 보내기",
|
||||
"95af781091": "agent에게 피드백 보내기",
|
||||
"ac39b9366b": "보내기",
|
||||
"a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.",
|
||||
"f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.",
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"e3bcd082ac": "连接到 Orca",
|
||||
"mobileScopeRejected": "此二维码仅授予受限(移动端)访问权限。若要使用完整 Web 应用,请从设置 → 远程 Orca 服务器 → 分享此 Orca 服务器 → 新链接打开浏览器访问链接。"
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
"api": {
|
||||
@@ -2588,8 +2591,7 @@
|
||||
"localWorkspacesOnly": "从历史恢复仅支持本地工作区。",
|
||||
"openLocalWorkspace": "恢复会话前请先打开一个本地工作区。",
|
||||
"sessionQueued": "会话已排队",
|
||||
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。",
|
||||
"sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。"
|
||||
},
|
||||
@@ -9648,9 +9650,7 @@
|
||||
"sessionsShownCompact": "已显示 {{value0}} 个",
|
||||
"originalPaneUnavailable": "原始面板不再可用。",
|
||||
"worktreeUnavailable": "工作树不再可用。",
|
||||
"runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。",
|
||||
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
|
||||
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。",
|
||||
"sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。"
|
||||
},
|
||||
@@ -11729,7 +11729,7 @@
|
||||
"f2d0c22d67": "删除注释 {{value0}}",
|
||||
"11c5084aa2": "清晰的注释",
|
||||
"734e4343ec": "清除浏览器注释",
|
||||
"95af781091": "向新智能体发送反馈",
|
||||
"95af781091": "向智能体发送反馈",
|
||||
"ac39b9366b": "发送",
|
||||
"a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。",
|
||||
"f796c774a4": "在上面输入 URL 即可开始浏览。",
|
||||
|
||||
@@ -114,10 +114,33 @@ describe('ai vault session storage compatibility', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never allows runtime or unknown targets', () => {
|
||||
it('allows runtime sessions only on the matching runtime target', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' })
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
targetStatus: 'runtime',
|
||||
targetExecutionHostId: 'runtime:env-1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
|
||||
sessionExecutionHostId: 'runtime:env-1',
|
||||
targetStatus: 'runtime',
|
||||
targetExecutionHostId: 'runtime:env-2'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: wslSessionFile,
|
||||
targetStatus: 'runtime',
|
||||
targetExecutionHostId: 'runtime:env-1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never allows unknown targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' })
|
||||
).toBe(false)
|
||||
@@ -149,8 +172,11 @@ describe('ai vault resume target ownership', () => {
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
|
||||
isSupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves runtime-owned worktree targets through their repo owner', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function isSupportedAiVaultResumeRepo(
|
||||
}
|
||||
|
||||
export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTargetStatus): boolean {
|
||||
return status === 'local' || status === 'ssh'
|
||||
return status === 'local' || status === 'ssh' || status === 'runtime'
|
||||
}
|
||||
|
||||
export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean {
|
||||
@@ -48,11 +48,20 @@ export function canResumeAiVaultSessionOnTarget(args: {
|
||||
targetStatus: AiVaultResumeTargetStatus
|
||||
targetExecutionHostId?: ExecutionHostId | null
|
||||
}): boolean {
|
||||
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
|
||||
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
|
||||
if (args.targetStatus === 'runtime') {
|
||||
// Runtime session stores live on one paired server; only queue resumes back
|
||||
// onto that exact server host.
|
||||
return Boolean(
|
||||
sessionExecutionHostId &&
|
||||
targetExecutionHostId &&
|
||||
sessionExecutionHostId === targetExecutionHostId
|
||||
)
|
||||
}
|
||||
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
|
||||
return false
|
||||
}
|
||||
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
|
||||
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
|
||||
if (sessionExecutionHostId) {
|
||||
if (targetExecutionHostId) {
|
||||
if (sessionExecutionHostId === targetExecutionHostId) {
|
||||
@@ -84,7 +93,8 @@ export function canResumeAiVaultSessionOnTarget(args: {
|
||||
export function isUnsupportedAiVaultResumeRepo(
|
||||
repo: AiVaultResumeRepoOwner | null | undefined
|
||||
): boolean {
|
||||
return getAiVaultResumeRepoTargetStatus(repo) === 'runtime'
|
||||
const status = getAiVaultResumeRepoTargetStatus(repo)
|
||||
return status !== 'unknown' && !isSupportedAiVaultResumeTargetStatus(status)
|
||||
}
|
||||
|
||||
export function getAiVaultResumeWorktreeTargetStatus(args: {
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockPasteDraftWhenAgentReady = vi.fn()
|
||||
const mockSeedNativeChatLaunchPrompt = vi.fn()
|
||||
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
|
||||
const mockTrack = vi.fn()
|
||||
const mockToastMessage = vi.fn()
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -80,7 +81,7 @@ vi.mock('@/store', () => ({
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { message: vi.fn(), error: mockToastError }
|
||||
toast: { message: mockToastMessage, error: mockToastError }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/tab-bar/reconcile-order', () => ({
|
||||
@@ -545,7 +546,7 @@ describe('launchAgentInNewTab', () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
const prompt = 'x'.repeat(25_000)
|
||||
|
||||
launchAgentInNewTab({
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt,
|
||||
@@ -553,6 +554,7 @@ describe('launchAgentInNewTab', () => {
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('promptDeliveryResult')
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
@@ -570,16 +572,46 @@ describe('launchAgentInNewTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('logs rejected non-deferred prompt delivery without exposing it to callers', async () => {
|
||||
const error = new Error('paste failed')
|
||||
const originalConsole = console
|
||||
const consoleError = vi.fn()
|
||||
vi.stubGlobal('console', { ...originalConsole, error: consoleError })
|
||||
mockPasteDraftWhenAgentReady.mockRejectedValue(error)
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
const prompt = 'x'.repeat(25_000)
|
||||
|
||||
try {
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt,
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('promptDeliveryResult')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(consoleError).toHaveBeenCalledWith('Prompt delivery failed after launch', error)
|
||||
} finally {
|
||||
vi.stubGlobal('console', originalConsole)
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds working after Command Code submit-after-ready prompt delivery', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
store.terminalLayoutsByTabId = { 'tab-1': { activeLeafId: LEAF_ID } }
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -610,17 +642,114 @@ describe('launchAgentInNewTab', () => {
|
||||
mockPasteDraftWhenAgentReady.mockResolvedValue(false)
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: false
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
|
||||
})
|
||||
|
||||
it('marks failed submit-after-ready delivery as notified after readiness timeout toast', async () => {
|
||||
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
|
||||
onTimeout?.()
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] }
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: true
|
||||
})
|
||||
expect(mockToastMessage).toHaveBeenCalledWith(
|
||||
"Your prompt wasn't sent — paste it once the agent is ready."
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a cancelled submit-after-ready launch notified when the user closed the tab', async () => {
|
||||
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
|
||||
onTimeout?.()
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
// User closed the tab before the agent became ready — it is gone from the list.
|
||||
store.tabsByWorktree = { 'wt-1': [] }
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: true
|
||||
})
|
||||
expect(mockToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a cancelled submit-after-ready launch notified when the user switched worktrees', async () => {
|
||||
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
|
||||
onTimeout?.()
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] }
|
||||
store.activeWorktreeId = 'wt-2'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: true
|
||||
})
|
||||
expect(mockToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a genuine launch failure unnotified so the caller surfaces it', async () => {
|
||||
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
|
||||
onTimeout?.()
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
// PTY never spawned: a real failure, not a user cancellation.
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null } as never] }
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
agent: 'command-code',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'large generated prompt',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
await expect(result?.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(mockToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues per-launch CLI arguments without putting generated prompts in argv', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export type LaunchAgentInNewTabResult = {
|
||||
tabId: string | null
|
||||
startupPlan: AgentStartupPlan
|
||||
pasteDraftAfterLaunch: boolean
|
||||
promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }>
|
||||
} | null
|
||||
|
||||
/**
|
||||
@@ -153,6 +154,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
let pasteDraftAfterLaunch: string | null = null
|
||||
let submitPastedPrompt = false
|
||||
let forcePasteAfterLaunch = false
|
||||
let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined
|
||||
|
||||
if (hasPrompt && promptDelivery === 'submit-after-ready') {
|
||||
// Why: generated multi-line prompts are too large to echo through a shell
|
||||
@@ -300,9 +302,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
// the user closed the tab or switched worktrees so the toast/telemetry
|
||||
// don't fire for user-initiated cancellation (mirrors the 5s launch
|
||||
// watchdog in QuickLaunchButton).
|
||||
const tabId = tab.id
|
||||
void deliverLaunchPromptToAgentTab({
|
||||
tabId,
|
||||
let failureNotified = false
|
||||
const deliveryPromise = deliverLaunchPromptToAgentTab({
|
||||
tabId: tab.id,
|
||||
content: pasteDraftAfterLaunch,
|
||||
agent,
|
||||
submit: submitPastedPrompt,
|
||||
@@ -310,28 +312,28 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
onTimeout: () => {
|
||||
const state = useAppStore.getState()
|
||||
const tabsForWorktree = state.tabsByWorktree[worktreeId] ?? []
|
||||
const tab = tabsForWorktree.find((t) => t.id === tabId)
|
||||
// Why: if the PTY never spawned, QuickLaunch's 5s watchdog already
|
||||
// surfaced the launch failure. Don't double-toast for the same root
|
||||
// cause. Looking up directly in `worktreeId` (not scanning every
|
||||
// worktree) also preserves "still in this worktree" intent.
|
||||
if (!tab) {
|
||||
return // tab closed by user
|
||||
const currentTab = tabsForWorktree.find((t) => t.id === tab.id)
|
||||
if (currentTab?.ptyId === null) {
|
||||
// Why: PTY never spawned — a genuine launch failure. Stay silent so
|
||||
// the single notice comes from the caller (source-control dialog
|
||||
// toast, or QuickLaunch's watchdog); leaving failureNotified false lets it fire.
|
||||
return
|
||||
}
|
||||
if (tab.ptyId === null) {
|
||||
return // launch failed; QuickLaunch handled the user-facing toast
|
||||
}
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
if (!currentTab || state.activeWorktreeId !== worktreeId) {
|
||||
// Why: user-initiated cancellation (closed the tab or switched
|
||||
// worktrees) — mark notified so the deferred source-control caller
|
||||
// suppresses its generic "couldn't start" toast too, not just this nudge.
|
||||
failureNotified = true
|
||||
return
|
||||
}
|
||||
const label = submitPastedPrompt ? 'prompt' : 'notes'
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.lib.launch.agent.in.new.tab.a5a1f7033f',
|
||||
"Your {{value0}} wasn't sent — paste it once the agent is ready.",
|
||||
{ value0: label }
|
||||
{ value0: submitPastedPrompt ? 'prompt' : 'notes' }
|
||||
)
|
||||
)
|
||||
failureNotified = true
|
||||
track('agent_error', {
|
||||
error_class: 'paste_readiness_timeout',
|
||||
agent_kind: tuiAgentToAgentKind(agent)
|
||||
@@ -342,11 +344,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
if (agent === 'command-code' && submitPastedPrompt) {
|
||||
// Why: Command Code has no prompt-submit hook; when Orca submits a
|
||||
// generated prompt after readiness, seed working at delivery time.
|
||||
seedCommandCodeSubmittedPromptStatus(tabId, trimmedPrompt)
|
||||
seedCommandCodeSubmittedPromptStatus(tab.id, trimmedPrompt)
|
||||
}
|
||||
onPromptDelivered?.()
|
||||
}
|
||||
return { delivered, failureNotified: !delivered && failureNotified }
|
||||
})
|
||||
if (promptDelivery === 'submit-after-ready') {
|
||||
promptDeliveryResult = deliveryPromise
|
||||
} else {
|
||||
void deliveryPromise.catch((error) =>
|
||||
console.error('Prompt delivery failed after launch', error)
|
||||
)
|
||||
}
|
||||
} else if (hasPrompt) {
|
||||
onPromptDelivered?.()
|
||||
}
|
||||
@@ -373,5 +383,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
order.push(tab.id)
|
||||
fresh.setTabBarOrder(worktreeId, order)
|
||||
|
||||
return { tabId: tab.id, startupPlan, pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null }
|
||||
return {
|
||||
tabId: tab.id,
|
||||
startupPlan,
|
||||
pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null,
|
||||
...(promptDeliveryResult ? { promptDeliveryResult } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ const mockCreateEmptySplitGroup = vi.fn()
|
||||
const mockQueueTabStartupCommand = vi.fn()
|
||||
const mockSetActiveTabType = vi.fn()
|
||||
const mockSetTabBarOrder = vi.fn()
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
createWebRuntimeSessionTerminal: vi.fn(),
|
||||
getRuntimeEnvironmentIdForWorktree: vi.fn<() => string | null>(() => null),
|
||||
isWebRuntimeSessionActive: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const mockState = {
|
||||
createTab: mockCreateTab,
|
||||
@@ -37,11 +42,23 @@ vi.mock('@/lib/telemetry', () => ({
|
||||
tuiAgentToAgentKind: (agent: string) => agent
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: runtimeMocks.getRuntimeEnvironmentIdForWorktree
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
createWebRuntimeSessionTerminal: runtimeMocks.createWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive: runtimeMocks.isWebRuntimeSessionActive
|
||||
}))
|
||||
|
||||
import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session'
|
||||
|
||||
describe('launchAiVaultSessionInNewTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null)
|
||||
runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false)
|
||||
runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue(true)
|
||||
mockState.tabsByWorktree = {}
|
||||
mockState.openFiles = []
|
||||
mockState.browserTabsByWorktree = {}
|
||||
@@ -118,4 +135,45 @@ describe('launchAiVaultSessionInNewTab', () => {
|
||||
expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', 'group-new')
|
||||
})
|
||||
|
||||
it('creates runtime-hosted resume terminals through the paired host', async () => {
|
||||
runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('env-1')
|
||||
runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true)
|
||||
|
||||
const result = launchAiVaultSessionInNewTab({
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
targetGroupId: 'group-1',
|
||||
command: "codex resume 'session-1'",
|
||||
env: { CODEX_PROFILE: 'runtime' },
|
||||
launchConfig: {
|
||||
agentCommand: 'codex',
|
||||
agentArgs: '',
|
||||
agentEnv: { CODEX_PROFILE: 'runtime' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.tabId).toBeNull()
|
||||
expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'env-1',
|
||||
targetGroupId: 'group-1',
|
||||
command: "codex resume 'session-1'",
|
||||
env: { CODEX_PROFILE: 'runtime' },
|
||||
launchConfig: {
|
||||
agentCommand: 'codex',
|
||||
agentArgs: '',
|
||||
agentEnv: { CODEX_PROFILE: 'runtime' }
|
||||
},
|
||||
launchAgent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
expect(mockQueueTabStartupCommand).not.toHaveBeenCalled()
|
||||
|
||||
if (result.tabId === null) {
|
||||
await expect(result.runtimeLaunch).resolves.toBe(true)
|
||||
}
|
||||
expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
createWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import type { AiVaultAgent } from '../../../shared/ai-vault-types'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
|
||||
import type { TabSplitDirection } from '@/store/slices/tabs'
|
||||
|
||||
export type LaunchAiVaultSessionInNewTabResult =
|
||||
| { tabId: string; groupId?: string }
|
||||
| { tabId: null; groupId?: string; runtimeLaunch: Promise<boolean> }
|
||||
|
||||
export function launchAiVaultSessionInNewTab(args: {
|
||||
agent: AiVaultAgent
|
||||
worktreeId: string
|
||||
@@ -13,9 +22,33 @@ export function launchAiVaultSessionInNewTab(args: {
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
targetGroupId?: string
|
||||
splitDirection?: TabSplitDirection
|
||||
}): { tabId: string; groupId?: string } {
|
||||
}): LaunchAiVaultSessionInNewTabResult {
|
||||
const store = useAppStore.getState()
|
||||
let targetGroupId = args.targetGroupId
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, args.worktreeId)
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
const runtimeLaunch = createWebRuntimeSessionTerminal({
|
||||
worktreeId: args.worktreeId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
...(targetGroupId ? { targetGroupId } : {}),
|
||||
command: args.command,
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
launchAgent: args.agent,
|
||||
activate: true
|
||||
}).then((created) => {
|
||||
if (created) {
|
||||
useAppStore.getState().setActiveTabType('terminal')
|
||||
}
|
||||
return created
|
||||
})
|
||||
return {
|
||||
tabId: null,
|
||||
...(targetGroupId ? { groupId: targetGroupId } : {}),
|
||||
runtimeLaunch
|
||||
}
|
||||
}
|
||||
|
||||
if (args.splitDirection && targetGroupId) {
|
||||
targetGroupId =
|
||||
store.createEmptySplitGroup(args.worktreeId, targetGroupId, args.splitDirection) ??
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user