mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(terminal): report execution host and listing scope in terminal list (#14973)
* feat(terminal): report execution host and listing scope in terminal list `orca terminal list` returned rows with no host identity and no statement of what the listing covered, so a scoped listing that saw nothing read as "nothing exists anywhere" — an agent reported a live remote worker dead. Each row now carries an optional `executionHostId` derived from the PTY id (SSH and paired-runtime ids embed their owner), and the result carries an optional `hostScope` naming the hosts covered and the known hosts skipped. Both are surfaced in `--json` and in the human-readable CLI output, where an absent field renders as `unknown` rather than `local`. Both row builders route through one resolver, so the rule lives in one place. * fix(terminal): preserve unverifiable host scope * fix(terminal): fail closed on unverifiable hosts * test(terminal): name unverifiable scope explicitly * perf(terminal): keep graph hydration host scans narrow * fix(terminal): reject blank foreign host owners * fix(terminal): validate inferred inventory hosts * fix(terminal): preserve paired folder host scope * fix(terminal): keep inventory host inference typed * fix(terminal): disclose paired folder hosts
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
const REMOTE_ROW = {
|
||||
handle: 'term_remote',
|
||||
ptyId: 'ssh:box-1@@pty-7',
|
||||
worktreeId: 'repo-ssh::/remote/wt',
|
||||
worktreePath: '/remote/wt',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
executionHostId: 'ssh:box-1'
|
||||
}
|
||||
|
||||
describe('orca terminal list host scope', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('keeps the execution host and scope in --json output', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [REMOTE_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1')
|
||||
expect(printed.result.hostScope).toEqual({
|
||||
hostIds: ['ssh:box-1'],
|
||||
omittedHostIds: ['local']
|
||||
})
|
||||
})
|
||||
|
||||
it('prints the execution host and scope in human output', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [REMOTE_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('host=ssh:box-1')
|
||||
expect(printed).toContain('scope: ssh:box-1')
|
||||
expect(printed).toContain('not covered: local')
|
||||
})
|
||||
|
||||
it('does not claim a local scope when the host never reported one', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', { terminals: [], totalCount: 0, truncated: false })
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('scope: unverifiable')
|
||||
expect(printed).not.toContain('scope: local')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalListHostScope,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalVisualLayout,
|
||||
RuntimeTerminalVisualLayoutNode,
|
||||
@@ -16,20 +17,34 @@ import type {
|
||||
} from '../shared/runtime-types'
|
||||
|
||||
export function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
const scope = formatTerminalListHostScope(result.hostScope)
|
||||
if (result.terminals.length === 0) {
|
||||
return 'No live terminals.'
|
||||
return `No terminals listed.\n${scope}`
|
||||
}
|
||||
const body = result.terminals
|
||||
.map(
|
||||
(terminal) =>
|
||||
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
||||
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
|
||||
const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body
|
||||
const bodyWithScope = `${bodyWithLayout}\n\n${scope}`
|
||||
return result.truncated
|
||||
? `${bodyWithLayout}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
||||
: bodyWithLayout
|
||||
? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
||||
: bodyWithScope
|
||||
}
|
||||
|
||||
// Why: a listing that does not say what it covers reads as absolute, and an
|
||||
// absent scope means the host is too old to know — not that it covered everything.
|
||||
function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string {
|
||||
if (!scope) {
|
||||
return 'scope: unverifiable — this host does not report which hosts it lists'
|
||||
}
|
||||
const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none'
|
||||
const omitted =
|
||||
scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : ''
|
||||
return `scope: ${covered}${omitted}`
|
||||
}
|
||||
|
||||
function formatTerminalVisualLayouts(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalList } from './format'
|
||||
import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../shared/runtime-types'
|
||||
|
||||
function terminal(overrides: Partial<RuntimeTerminalSummary> = {}): RuntimeTerminalSummary {
|
||||
return {
|
||||
handle: 'term_1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo::/repo',
|
||||
worktreePath: '/repo',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function listResult(overrides: Partial<RuntimeTerminalListResult> = {}): RuntimeTerminalListResult {
|
||||
return { terminals: [terminal()], totalCount: 1, truncated: false, ...overrides }
|
||||
}
|
||||
|
||||
describe('formatTerminalList host identity', () => {
|
||||
it('prints the execution host each terminal runs on', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({ terminals: [terminal({ executionHostId: 'ssh:box-1' })] })
|
||||
)
|
||||
|
||||
expect(output).toContain('host=ssh:box-1')
|
||||
})
|
||||
|
||||
it('prints unverifiable, not local, for a row whose host the runtime could not name', () => {
|
||||
const output = formatTerminalList(listResult({ terminals: [terminal()] }))
|
||||
|
||||
expect(output).toContain('host=unverifiable')
|
||||
expect(output).not.toContain('host=local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalList scope declaration', () => {
|
||||
it('states the covered and omitted hosts', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(output).toContain('scope: local')
|
||||
expect(output).toContain('not covered: ssh:box-1')
|
||||
})
|
||||
|
||||
it('keeps an empty listing self-describing instead of reading as absolute', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(output).toContain('No terminals listed')
|
||||
expect(output).toContain('scope: local')
|
||||
expect(output).toContain('not covered: ssh:box-1')
|
||||
})
|
||||
|
||||
it('says the scope is unverifiable when the host predates the field', () => {
|
||||
const output = formatTerminalList(listResult())
|
||||
|
||||
expect(output).toContain('scope: unverifiable')
|
||||
expect(output).not.toContain('scope: local')
|
||||
})
|
||||
})
|
||||
+24
-6
@@ -17,7 +17,11 @@ import type { PtyBindingSourceExpectation, Store } from '../persistence'
|
||||
import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
|
||||
import type {
|
||||
@@ -72,6 +76,7 @@ import {
|
||||
} from '../../shared/pi-agent-kind'
|
||||
import { isPwshAvailableAsync } from '../pwsh'
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import type { PtyProcessInfo } from '../providers/pty-process-info'
|
||||
import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
@@ -259,6 +264,22 @@ function registeredPtyProviders(): RegisteredPtyProvider[] {
|
||||
]
|
||||
}
|
||||
|
||||
async function listRegisteredPtyProcessesWithHostScope(): Promise<{
|
||||
processes: PtyProcessInfo[]
|
||||
hostIds: ExecutionHostId[]
|
||||
}> {
|
||||
const providers = registeredPtyProviders()
|
||||
const providerSessions = await Promise.all(
|
||||
providers.map(({ provider }) => provider.listProcesses())
|
||||
)
|
||||
return {
|
||||
processes: providerSessions.flat(),
|
||||
hostIds: providers.map(({ connectionId }) =>
|
||||
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
|
||||
// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring.
|
||||
const PRODUCER_FLOW_CONTROL_ENABLED = true
|
||||
@@ -5747,12 +5768,9 @@ export function registerPtyHandlers(
|
||||
if (connectionId !== undefined) {
|
||||
return getProvider(connectionId).listProcesses()
|
||||
}
|
||||
const providerSessions = await Promise.all([
|
||||
localProvider.listProcesses(),
|
||||
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
|
||||
])
|
||||
return providerSessions.flat()
|
||||
return (await listRegisteredPtyProcessesWithHostScope()).processes
|
||||
},
|
||||
listProcessesWithHostScope: listRegisteredPtyProcessesWithHostScope,
|
||||
serializeBuffer: (ptyId, opts) => {
|
||||
// Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly.
|
||||
return requestSerializedBuffer(ptyId, opts)
|
||||
|
||||
@@ -421,6 +421,7 @@ import {
|
||||
type RuntimeTerminalSplit,
|
||||
type RuntimeTerminalFocus,
|
||||
type RuntimeTerminalClose,
|
||||
type RuntimeTerminalListHostScope,
|
||||
type RuntimeTerminalListResult,
|
||||
type RuntimeTerminalOrphanAdoptionRequest,
|
||||
type RuntimeTerminalOrphanAdoptionResult,
|
||||
@@ -516,6 +517,7 @@ import {
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import { getPtyExecutionHost } from '../../shared/terminal-execution-host'
|
||||
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
import {
|
||||
@@ -1867,6 +1869,10 @@ type RuntimePtyController = {
|
||||
// Why: exact-id mobile polls should not enumerate every local and SSH PTY.
|
||||
hasPty?(ptyId: string): boolean | null
|
||||
listProcesses?(connectionId?: string | null): Promise<PtyProcessInfo[]>
|
||||
listProcessesWithHostScope?(): Promise<{
|
||||
processes: PtyProcessInfo[]
|
||||
hostIds: ExecutionHostId[]
|
||||
}>
|
||||
serializeBuffer?(
|
||||
ptyId: string,
|
||||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||||
@@ -1911,6 +1917,7 @@ type PtyControllerInventory = Readonly<{
|
||||
// must consult the unscoped inventory or a misattributed live PTY reads as dead.
|
||||
allLivePtyIds: ReadonlySet<string>
|
||||
terminalIdentityByPtyId: ReadonlyMap<string, PtyControllerTerminalIdentity>
|
||||
queriedHostIds: ReadonlySet<ExecutionHostId>
|
||||
}>
|
||||
|
||||
type WorktreeStartupDraftPaste = {
|
||||
@@ -5769,6 +5776,9 @@ export class OrcaRuntimeService {
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
if (workspace.executionHostId != null) {
|
||||
return parseExecutionHostId(workspace.executionHostId)?.id ?? null
|
||||
}
|
||||
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
|
||||
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
@@ -5816,6 +5826,48 @@ export class OrcaRuntimeService {
|
||||
return worktreeIds
|
||||
}
|
||||
|
||||
// Every execution host known to this runtime; knowledge is not coverage.
|
||||
private listKnownExecutionHostIds(
|
||||
additionalHostIds: Iterable<ExecutionHostId> = [],
|
||||
includeConfiguredHosts = true
|
||||
): Set<ExecutionHostId> {
|
||||
const hostIds = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
|
||||
for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
for (const hostId of additionalHostIds) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
if (!includeConfiguredHosts) {
|
||||
return hostIds
|
||||
}
|
||||
const repos = this.store?.getRepos?.() ?? []
|
||||
for (const repo of repos) {
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
}
|
||||
const projectGroups = this.store?.getProjectGroups?.() ?? []
|
||||
for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) {
|
||||
if (workspace.executionHostId != null) {
|
||||
const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id
|
||||
if (explicitHostId) {
|
||||
hostIds.add(explicitHostId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const connection = inferFolderWorkspacePathConnection({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: workspace.connectionId ?? null,
|
||||
projectGroups,
|
||||
repos
|
||||
})
|
||||
if (connection.kind === 'ssh') {
|
||||
hostIds.add(toSshExecutionHostId(connection.connectionId))
|
||||
}
|
||||
}
|
||||
return hostIds
|
||||
}
|
||||
|
||||
private getWorkspaceSessionHydrationTargets(
|
||||
includeAllPersistedWorktrees: boolean
|
||||
): Map<string, WorkspaceSessionState> {
|
||||
@@ -5832,7 +5884,7 @@ export class OrcaRuntimeService {
|
||||
] as const
|
||||
})
|
||||
)
|
||||
const hostIds = new Set<ExecutionHostId>(['local'])
|
||||
const hostIds = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
|
||||
for (const repo of repos) {
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
}
|
||||
@@ -16646,6 +16698,12 @@ export class OrcaRuntimeService {
|
||||
|
||||
return {
|
||||
terminals: listedTerminals,
|
||||
hostScope: this.buildTerminalListHostScope(
|
||||
targetWorktreeId,
|
||||
matchingTerminals,
|
||||
worktreesById.values(),
|
||||
controllerInventory?.queriedHostIds ?? new Set()
|
||||
),
|
||||
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
|
||||
topologyRevisions: Object.fromEntries(
|
||||
[...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [
|
||||
@@ -16658,6 +16716,52 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// A worktree-scoped request answers for one host only, so name the hosts it
|
||||
// skipped: absence from a scoped listing is not evidence a worker exited.
|
||||
private buildTerminalListHostScope(
|
||||
targetWorktreeId: string | null,
|
||||
terminals: readonly RuntimeTerminalSummary[],
|
||||
worktrees: Iterable<ResolvedWorktree>,
|
||||
queriedHostIds: ReadonlySet<ExecutionHostId>
|
||||
): RuntimeTerminalListHostScope {
|
||||
const knownHostIds = this.listKnownExecutionHostIds(
|
||||
queriedHostIds,
|
||||
targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID
|
||||
)
|
||||
let resolvedTargetHostId: ExecutionHostId | null = null
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.hostId) {
|
||||
knownHostIds.add(worktree.hostId)
|
||||
if (worktree.id === targetWorktreeId) {
|
||||
resolvedTargetHostId = worktree.hostId
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const terminal of terminals) {
|
||||
if (terminal.executionHostId) {
|
||||
knownHostIds.add(terminal.executionHostId)
|
||||
}
|
||||
}
|
||||
const scopedHostId = targetWorktreeId
|
||||
? (resolvedTargetHostId ?? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId))
|
||||
: null
|
||||
if (scopedHostId) {
|
||||
knownHostIds.add(scopedHostId)
|
||||
}
|
||||
const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds
|
||||
// Paired runtimes own a separate control plane. Mirrored rows are evidence
|
||||
// for those rows only; this runtime cannot claim their complete inventory.
|
||||
const coveredHostIds = new Set(
|
||||
[...candidates].filter(
|
||||
(hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime'
|
||||
)
|
||||
)
|
||||
return {
|
||||
hostIds: [...coveredHostIds].sort(),
|
||||
omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort()
|
||||
}
|
||||
}
|
||||
|
||||
async inspectTerminalProcessIncarnationLiveness(
|
||||
processIncarnation: string,
|
||||
serializedHostScope: string | null
|
||||
@@ -31215,7 +31319,8 @@ export class OrcaRuntimeService {
|
||||
return {
|
||||
livePtyIds: targetedLiveness,
|
||||
allLivePtyIds: targetedLiveness,
|
||||
terminalIdentityByPtyId: new Map()
|
||||
terminalIdentityByPtyId: new Map(),
|
||||
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31230,8 +31335,32 @@ export class OrcaRuntimeService {
|
||||
} else {
|
||||
this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration)
|
||||
}
|
||||
const processInventory =
|
||||
connectionId === undefined && this.ptyController.listProcessesWithHostScope
|
||||
? this.ptyController.listProcessesWithHostScope()
|
||||
: this.ptyController.listProcesses(connectionId).then((processes) => {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
if (connectionId === undefined || connectionId === null) {
|
||||
hostIds.add(LOCAL_EXECUTION_HOST_ID)
|
||||
} else {
|
||||
hostIds.add(toSshExecutionHostId(connectionId))
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
for (const process of processes) {
|
||||
const hostId = getPtyExecutionHost(process.id)
|
||||
if (
|
||||
hostId &&
|
||||
hostId !== 'foreign' &&
|
||||
parseExecutionHostId(hostId)?.kind === 'ssh'
|
||||
) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { processes, hostIds: [...hostIds] }
|
||||
})
|
||||
const sessionsResult = await withTimeoutResult(
|
||||
this.ptyController.listProcesses(connectionId),
|
||||
processInventory,
|
||||
deadline === undefined
|
||||
? PTY_CONTROLLER_LIST_TIMEOUT_MS
|
||||
: Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now()))
|
||||
@@ -31252,7 +31381,8 @@ export class OrcaRuntimeService {
|
||||
if (!isCurrentInventory) {
|
||||
return null
|
||||
}
|
||||
const sessions = sessionsResult.value
|
||||
const sessions = sessionsResult.value.processes
|
||||
const queriedHostIds = new Set(sessionsResult.value.hostIds)
|
||||
const controllerIdentityByPtyId = new Map<string, PtyControllerTerminalIdentity>()
|
||||
const ptyIdByControllerHandle = new Map<string, string>()
|
||||
const ambiguousControllerPtyIds = new Set<string>()
|
||||
@@ -31431,7 +31561,8 @@ export class OrcaRuntimeService {
|
||||
return {
|
||||
livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds,
|
||||
allLivePtyIds,
|
||||
terminalIdentityByPtyId: controllerIdentityByPtyId
|
||||
terminalIdentityByPtyId: controllerIdentityByPtyId,
|
||||
queriedHostIds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31680,10 +31811,26 @@ export class OrcaRuntimeService {
|
||||
connected: provenAbsent ? false : leaf.connected,
|
||||
writable: provenAbsent ? false : leaf.writable,
|
||||
lastOutputAt: leaf.lastOutputAt,
|
||||
preview: leaf.preview
|
||||
preview: leaf.preview,
|
||||
...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the PTY id names its own host when it has one; only a host-less id may
|
||||
// fall back to the worktree's. A foreign id with no owner stays unset rather
|
||||
// than inheriting a local worktree's host and reading as local.
|
||||
private terminalExecutionHostField(
|
||||
ptyId: string | null,
|
||||
worktreeId: string
|
||||
): { executionHostId?: ExecutionHostId } {
|
||||
const fromPtyId = getPtyExecutionHost(ptyId)
|
||||
if (fromPtyId === 'foreign') {
|
||||
return {}
|
||||
}
|
||||
const hostId = fromPtyId ?? this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
return hostId ? { executionHostId: hostId } : {}
|
||||
}
|
||||
|
||||
// Returns the worktrees whose stored snapshot object changed during this
|
||||
// sync, so the caller can fan out only actually-changed worktrees.
|
||||
private syncMobileSessionTabs(
|
||||
@@ -33622,7 +33769,8 @@ export class OrcaRuntimeService {
|
||||
connected: pty.connected,
|
||||
writable: pty.connected,
|
||||
lastOutputAt: pty.lastOutputAt,
|
||||
preview: pty.preview
|
||||
preview: pty.preview,
|
||||
...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
|
||||
// Request options in this codebase have been dropped by a transport that
|
||||
// forgot to forward them. The host-scope answer must survive the RPC boundary
|
||||
// the same way, so assert it on the far side of the dispatcher.
|
||||
const LIST_RESULT: RuntimeTerminalListResult = {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_remote',
|
||||
ptyId: 'ssh:box-1@@pty-7',
|
||||
worktreeId: 'repo-ssh::/remote/wt',
|
||||
worktreePath: '/remote/wt',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
executionHostId: 'ssh:box-1'
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
}
|
||||
|
||||
describe('terminal.list RPC boundary', () => {
|
||||
it('forwards the execution host and scope the runtime reported', async () => {
|
||||
const runtime = {
|
||||
listTerminals: vi.fn(async () => LIST_RESULT),
|
||||
getRuntimeId: () => 'runtime-a'
|
||||
}
|
||||
const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch({
|
||||
id: 'request-1',
|
||||
authToken: 'test-token',
|
||||
method: 'terminal.list',
|
||||
params: {}
|
||||
})
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
const result = (response as { result: RuntimeTerminalListResult }).result
|
||||
expect(result.terminals[0]?.executionHostId).toBe('ssh:box-1')
|
||||
expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: ['local'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { folderWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
|
||||
// An agent once declared a task exited because `terminal list` came back without
|
||||
// its worker: the worker was live on an SSH host, and nothing in the response
|
||||
// said the listing was scoped or which host each row ran on.
|
||||
|
||||
const LOCAL_WORKTREE_ID = 'repo-local::/tmp/local-worktree'
|
||||
const SSH_WORKTREE_ID = 'repo-ssh::/remote/ssh-worktree'
|
||||
const LOCAL_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SSH_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const REMOTE_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
const REPOS = [
|
||||
{
|
||||
id: 'repo-local',
|
||||
path: '/tmp/local-worktree',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
},
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/ssh-worktree',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
connectionId: 'box-1'
|
||||
}
|
||||
]
|
||||
|
||||
function makeStore() {
|
||||
const session: WorkspaceSessionState = getDefaultWorkspaceSession()
|
||||
return {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local', 'ssh:box-1']),
|
||||
getFolderWorkspaces: vi.fn((): FolderWorkspace[] => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
setWorkspaceSession: vi.fn(),
|
||||
getRepos: vi.fn(() => REPOS),
|
||||
getRepo: vi.fn((id: string) => REPOS.find((repo) => repo.id === id)),
|
||||
getAllWorktreeMeta: vi.fn(() => ({})),
|
||||
getWorktreeMeta: vi.fn(() => undefined),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })),
|
||||
getProjects: vi.fn(() => [])
|
||||
}
|
||||
}
|
||||
|
||||
function makeSshFolderWorkspace() {
|
||||
return {
|
||||
id: 'folder-ssh',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'SSH folder',
|
||||
folderPath: '/remote/folder',
|
||||
connectionId: 'box-2',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeRuntimeFolderWorkspace() {
|
||||
return {
|
||||
...makeSshFolderWorkspace(),
|
||||
id: 'folder-runtime',
|
||||
name: 'Runtime folder',
|
||||
connectionId: 'stale-box',
|
||||
executionHostId: 'runtime:env-9' as const
|
||||
}
|
||||
}
|
||||
|
||||
type GraphLeaf = { worktreeId: string; leafId: string; ptyId: string }
|
||||
|
||||
function makeRuntime(leaves: GraphLeaf[], store = makeStore()): OrcaRuntimeService {
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => leaves.map((leaf) => ({ id: leaf.ptyId, cwd: '/tmp' })))
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: leaves.map((leaf, index) => ({
|
||||
tabId: `tab-${index + 1}`,
|
||||
worktreeId: leaf.worktreeId,
|
||||
title: '',
|
||||
activeLeafId: leaf.leafId,
|
||||
layout: null
|
||||
})),
|
||||
leaves: leaves.map((leaf, index) => ({
|
||||
tabId: `tab-${index + 1}`,
|
||||
worktreeId: leaf.worktreeId,
|
||||
leafId: leaf.leafId,
|
||||
paneRuntimeId: index + 1,
|
||||
ptyId: leaf.ptyId,
|
||||
paneTitle: null,
|
||||
title: ''
|
||||
}))
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('listTerminals execution-host identity', () => {
|
||||
it('names the SSH connection a remote terminal runs on instead of local', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{ worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' },
|
||||
{ worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' }
|
||||
])
|
||||
|
||||
const { terminals } = await runtime.listTerminals()
|
||||
|
||||
const sshRow = terminals.find((terminal) => terminal.ptyId === 'ssh:box-1@@pty-7')
|
||||
const localRow = terminals.find((terminal) => terminal.ptyId === 'pty-local-1')
|
||||
expect(sshRow?.executionHostId).toBe('ssh:box-1')
|
||||
expect(localRow?.executionHostId).toBe('local')
|
||||
})
|
||||
|
||||
it('names the paired runtime environment a mirrored terminal belongs to', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{
|
||||
worktreeId: LOCAL_WORKTREE_ID,
|
||||
leafId: REMOTE_LEAF_ID,
|
||||
ptyId: 'remote:env-7@@handle-1'
|
||||
}
|
||||
])
|
||||
|
||||
const { terminals } = await runtime.listTerminals()
|
||||
|
||||
expect(terminals[0]?.executionHostId).toBe('runtime:env-7')
|
||||
})
|
||||
|
||||
it('leaves the host unset — never local — for a paired PTY that names no environment', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{ worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId: 'remote:handle-1' }
|
||||
])
|
||||
|
||||
const { hostScope, terminals } = await runtime.listTerminals()
|
||||
|
||||
expect(terminals[0]?.executionHostId).toBeUndefined()
|
||||
expect(hostScope?.hostIds).toEqual(['local'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'remote:env@@%E0%A4%A',
|
||||
'remote:%20@@terminal%3Aone',
|
||||
'ssh:%E0%A4%A@@pty-7',
|
||||
'ssh:%20@@pty-7'
|
||||
])('leaves the host unset for a malformed foreign PTY id: %s', async (ptyId) => {
|
||||
const runtime = makeRuntime([{ worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId }])
|
||||
|
||||
const { hostScope, terminals } = await runtime.listTerminals()
|
||||
|
||||
expect(terminals[0]?.executionHostId).toBeUndefined()
|
||||
expect(hostScope?.hostIds).toEqual(['local'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listTerminals scope declaration', () => {
|
||||
it('declares every host an unscoped listing covers', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{ worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' },
|
||||
{ worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' }
|
||||
])
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local', 'ssh:box-1'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
it('does not claim a paired runtime was covered from a mirrored row', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{
|
||||
worktreeId: LOCAL_WORKTREE_ID,
|
||||
leafId: REMOTE_LEAF_ID,
|
||||
ptyId: 'remote:env-7@@handle-1'
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-7', 'ssh:box-1'])
|
||||
})
|
||||
|
||||
it('keeps a repo-known paired runtime omitted without a mirrored row', async () => {
|
||||
const baseStore = makeStore()
|
||||
const repos = [
|
||||
REPOS[0]!,
|
||||
{
|
||||
id: 'repo-runtime',
|
||||
path: '/remote/runtime-worktree',
|
||||
displayName: 'runtime',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-9' as const
|
||||
}
|
||||
]
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => repos),
|
||||
getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local'])
|
||||
})
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9'])
|
||||
})
|
||||
|
||||
it('keeps a repo-known paired runtime omitted in a worktree-scoped listing', async () => {
|
||||
const baseStore = makeStore()
|
||||
const repos = [
|
||||
REPOS[0]!,
|
||||
{
|
||||
id: 'repo-runtime',
|
||||
path: '/remote/runtime-worktree',
|
||||
displayName: 'runtime',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-9' as const
|
||||
}
|
||||
]
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => repos),
|
||||
getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local'])
|
||||
})
|
||||
|
||||
const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9'])
|
||||
})
|
||||
|
||||
it('covers a queried SSH host used only by a folder workspace', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeSshFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => []),
|
||||
listProcessesWithHostScope: vi.fn(async () => ({
|
||||
processes: [],
|
||||
hostIds: ['local', 'ssh:box-2']
|
||||
}))
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['ssh:box-2'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('keeps a disconnected folder-workspace SSH host omitted', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeSshFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unavailable')
|
||||
})
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual([])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-2'])
|
||||
})
|
||||
|
||||
it('does not claim local coverage for a paired-runtime folder workspace', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeRuntimeFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
|
||||
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual([])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'runtime:env-9'])
|
||||
})
|
||||
|
||||
it('keeps a paired-runtime folder owner omitted in an unscoped listing', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeRuntimeFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9'])
|
||||
})
|
||||
|
||||
it('keeps a disconnected SSH host omitted when only local inventory answered', async () => {
|
||||
const runtime = makeRuntime([])
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => [])
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1'])
|
||||
})
|
||||
|
||||
it('marks every known host omitted when process inventory is unverifiable', async () => {
|
||||
const runtime = makeRuntime([])
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unavailable')
|
||||
})
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual([])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-1'])
|
||||
})
|
||||
|
||||
it('reports the hosts a worktree-scoped listing skipped, so an empty result is not absolute', async () => {
|
||||
const runtime = makeRuntime([
|
||||
{ worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' }
|
||||
])
|
||||
|
||||
const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`)
|
||||
|
||||
expect(result.terminals).toEqual([])
|
||||
expect(result.hostScope?.hostIds).toEqual(['local'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/host/app'
|
||||
}
|
||||
}))
|
||||
vi.mock('../persistence', () => ({
|
||||
getCanonicalUserDataPath: () => '/host/user-data'
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
|
||||
// Why: a missing CLI entry forces the legacy in-process bridge, the transport
|
||||
// an SSH-hosted agent actually reaches `terminal list` through.
|
||||
const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
|
||||
execPath: '/host/electron',
|
||||
cliEntryPath: '/host/app/out/cli/index.js',
|
||||
userDataPath: '/host/user-data',
|
||||
entryExists: () => false
|
||||
}
|
||||
|
||||
describe('remote CLI bridge terminal list', () => {
|
||||
it('relays the execution host and scope to an SSH-hosted caller', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'listTerminals').mockResolvedValue({
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_remote',
|
||||
ptyId: 'ssh:box-1@@pty-7',
|
||||
worktreeId: 'repo-ssh::/remote/wt',
|
||||
worktreePath: '/remote/wt',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
executionHostId: 'ssh:box-1'
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
})
|
||||
|
||||
const result = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{ argv: ['terminal', 'list', '--json'], cwd: '/home/alice/repo', env: {} },
|
||||
LEGACY_FALLBACK_OPTIONS
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
result: {
|
||||
terminals: { executionHostId?: string }[]
|
||||
hostScope?: { hostIds: string[]; omittedHostIds: string[] }
|
||||
}
|
||||
}
|
||||
expect(payload.result.terminals[0]?.executionHostId).toBe('ssh:box-1')
|
||||
expect(payload.result.hostScope).toEqual({
|
||||
hostIds: ['ssh:box-1'],
|
||||
omittedHostIds: ['local']
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer'
|
||||
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
|
||||
|
||||
export {
|
||||
parseRemoteRuntimePtyId,
|
||||
toRemoteRuntimePtyId,
|
||||
type RemoteRuntimePtyIdParts
|
||||
} from '../../../shared/remote-runtime-pty-id'
|
||||
|
||||
const REMOTE_PTY_ID_PREFIX = 'remote:'
|
||||
const REMOTE_PTY_OWNER_SEPARATOR = '@@'
|
||||
const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000
|
||||
|
||||
export type RemoteRuntimePtyIdParts = {
|
||||
environmentId: string | null
|
||||
handle: string
|
||||
}
|
||||
|
||||
export type RuntimeTerminalDataSubscriptionOptions = {
|
||||
startAtLiveTail?: boolean
|
||||
onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
|
||||
@@ -19,33 +19,6 @@ export type RuntimeTerminalDataSubscriptionOptions = {
|
||||
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void
|
||||
}
|
||||
|
||||
export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string {
|
||||
const owner = environmentId?.trim()
|
||||
if (!owner) {
|
||||
return `${REMOTE_PTY_ID_PREFIX}${handle}`
|
||||
}
|
||||
return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}`
|
||||
}
|
||||
|
||||
export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null {
|
||||
if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length)
|
||||
const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR)
|
||||
if (separatorIndex === -1) {
|
||||
return { environmentId: null, handle: rest }
|
||||
}
|
||||
try {
|
||||
return {
|
||||
environmentId: decodeURIComponent(rest.slice(0, separatorIndex)),
|
||||
handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemoteRuntimeTerminalHandle(ptyId: string): string | null {
|
||||
return parseRemoteRuntimePtyId(ptyId)?.handle ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Why: shared so main can name the paired runtime a mirrored PTY belongs to;
|
||||
// the encoding lives here rather than in the renderer transport that mints it.
|
||||
const REMOTE_PTY_ID_PREFIX = 'remote:'
|
||||
const REMOTE_PTY_OWNER_SEPARATOR = '@@'
|
||||
|
||||
export type RemoteRuntimePtyIdParts = {
|
||||
environmentId: string | null
|
||||
handle: string
|
||||
}
|
||||
|
||||
export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string {
|
||||
const owner = environmentId?.trim()
|
||||
if (!owner) {
|
||||
return `${REMOTE_PTY_ID_PREFIX}${handle}`
|
||||
}
|
||||
return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}`
|
||||
}
|
||||
|
||||
export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null {
|
||||
if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length)
|
||||
const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR)
|
||||
if (separatorIndex === -1) {
|
||||
return { environmentId: null, handle: rest }
|
||||
}
|
||||
try {
|
||||
return {
|
||||
environmentId: decodeURIComponent(rest.slice(0, separatorIndex)),
|
||||
handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,9 @@ export type RuntimeTerminalSummary = {
|
||||
writable: boolean
|
||||
lastOutputAt: number | null
|
||||
preview: string
|
||||
/** Where this terminal actually runs. Absent when the host predates the field
|
||||
* or could not name the host — never read an absent value as local. */
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type RuntimeTerminalVisualTerminalNode = {
|
||||
@@ -515,12 +518,24 @@ export type RuntimeTerminalVisualLayout = {
|
||||
root: RuntimeTerminalVisualLayoutNode
|
||||
}
|
||||
|
||||
/** Which execution hosts a listing answered for, so an empty or partial result
|
||||
* reads as "none here" instead of "none anywhere". Host ids are as the
|
||||
* answering runtime names them — `_meta.runtimeId` says which runtime that is. */
|
||||
export type RuntimeTerminalListHostScope = {
|
||||
hostIds: ExecutionHostId[]
|
||||
/** Known hosts this listing skipped; a live terminal on one of them can be
|
||||
* absent from `terminals` without having exited. */
|
||||
omittedHostIds: ExecutionHostId[]
|
||||
}
|
||||
|
||||
export type RuntimeTerminalListResult = {
|
||||
terminals: RuntimeTerminalSummary[]
|
||||
visualLayouts?: RuntimeTerminalVisualLayout[]
|
||||
topologyRevisions?: Record<string, number>
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
/** Absent from hosts that predate the field — treat that scope as unverifiable. */
|
||||
hostScope?: RuntimeTerminalListHostScope
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionClaim = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import { parseRemoteRuntimePtyId } from './remote-runtime-pty-id'
|
||||
import { parseAppSshPtyId } from './ssh-pty-id'
|
||||
|
||||
/** 'foreign' = the id proves the PTY runs off this host but cannot name where. */
|
||||
export type PtyExecutionHost = ExecutionHostId | 'foreign' | null
|
||||
|
||||
// Why: a PTY id, not a path, is the authority on where a terminal runs — SSH and
|
||||
// paired-runtime ids embed their owner. `null` means the id carries no host at
|
||||
// all, so the caller may fall back to the worktree's host.
|
||||
export function getPtyExecutionHost(ptyId: string | null | undefined): PtyExecutionHost {
|
||||
if (!ptyId) {
|
||||
return null
|
||||
}
|
||||
const ssh = parseAppSshPtyId(ptyId)
|
||||
if (ssh) {
|
||||
const connectionId = ssh.connectionId.trim()
|
||||
return connectionId && connectionId === ssh.connectionId
|
||||
? toSshExecutionHostId(connectionId)
|
||||
: 'foreign'
|
||||
}
|
||||
const remote = parseRemoteRuntimePtyId(ptyId)
|
||||
if (remote) {
|
||||
const environmentId = remote.environmentId?.trim()
|
||||
return environmentId && environmentId === remote.environmentId
|
||||
? toRuntimeExecutionHostId(environmentId)
|
||||
: 'foreign'
|
||||
}
|
||||
if (ptyId.startsWith('ssh:') || ptyId.startsWith('remote:')) {
|
||||
return 'foreign'
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user