mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): agents for workspaces another runtime owns show why (#27196)
* fix(mobile): agents for workspaces another runtime owns show why The paired host no longer lists its own agents for a workspace another Orca server owns: preflight.detectAgents refuses with workspace_on_other_runtime, and the phone shows 'Agents on Another Server - Pair that server directly'. The phone also recognises an unambiguous runtime-owned repo row itself, so an older host is never asked. * fix(mobile): a shared repo id with a runtime-owned row defers to a refusing host Rows on several hosts can share a repo id, and the first row's SSH connection could name the paired host's own target. When any row is runtime-owned the phone now asks a host that advertises preflight.other-runtime-refusal.v1 to decide by worktree, and shows the other-server row on an older host. * fix(mobile): keep the agent-loader change inside the web bundle budget Move the refusal code beside its capability in protocol-version, read the capability in the loader, and test the runtime: prefix directly: importing execution-host into the loader split a web bundle chunk (129 > 128 assets). --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
|
||||
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
|
||||
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/protocol-version'
|
||||
import {
|
||||
loadMobileNewTabAgentOptions,
|
||||
MobileWorkspaceOnOtherRuntimeError
|
||||
} from './mobile-new-tab-agent-loader'
|
||||
|
||||
function createClient(
|
||||
handler: (method: string, params?: unknown) => Promise<unknown>
|
||||
@@ -69,4 +73,94 @@ describe('mobile new-tab agent loading', () => {
|
||||
'preflight.detectRemoteAgents'
|
||||
])
|
||||
})
|
||||
|
||||
it('#13752: names another runtime instead of listing the paired host for its workspace', async () => {
|
||||
const client = createClient(async (method) => {
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'repo.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [{ id: 'repo-1', executionHostId: 'runtime:env-b', connectionId: 'b-ssh' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' })
|
||||
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
|
||||
// Neither the paired host nor its SSH targets are probed for a workspace it does not own.
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'repo.list',
|
||||
'settings.get'
|
||||
])
|
||||
})
|
||||
|
||||
// Rows on two hosts share the id; the SSH row first, so its connection would name this host's target.
|
||||
function sharedRepoIdClient(detectAgents: () => unknown) {
|
||||
return createClient(async (method, params) => {
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'repo.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [
|
||||
{ id: 'repo-1', executionHostId: 'ssh:a-target', connectionId: 'a-target' },
|
||||
{ id: 'repo-1', executionHostId: 'runtime:env-b' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
expect(params).toEqual({ worktreeId: 'repo-1::/srv/worktree' })
|
||||
return detectAgents()
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
})
|
||||
}
|
||||
|
||||
it('#13752: lets a refusing host decide a shared repo id, and reads its refusal', async () => {
|
||||
const refused = sharedRepoIdClient(() => ({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME }
|
||||
}))
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({
|
||||
client: refused,
|
||||
worktreeId: 'repo-1::/srv/worktree',
|
||||
hostRefusesOtherRuntime: true
|
||||
})
|
||||
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
|
||||
|
||||
const answered = sharedRepoIdClient(() => ({ ok: true, result: ['claude'] }))
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({
|
||||
client: answered,
|
||||
worktreeId: 'repo-1::/srv/worktree',
|
||||
hostRefusesOtherRuntime: true
|
||||
})
|
||||
).resolves.toEqual([{ agent: 'claude', label: 'Claude' }])
|
||||
expect(answered.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'repo.list',
|
||||
'settings.get',
|
||||
'preflight.detectAgents'
|
||||
])
|
||||
})
|
||||
|
||||
it('#13752: an older host cannot decide a shared repo id, so nothing is probed', async () => {
|
||||
const client = sharedRepoIdClient(() => ({ ok: true, result: ['codex'] }))
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' })
|
||||
).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError)
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'repo.list',
|
||||
'settings.get'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
|
||||
WORKSPACE_ON_OTHER_RUNTIME
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { newTabSettingsRead } from '../transport/settings-read-operations'
|
||||
import {
|
||||
type MobileRuntimeRepoSummary,
|
||||
@@ -15,6 +19,17 @@ import {
|
||||
type MobileNewTabAgentSettings
|
||||
} from './mobile-new-tab-agent-options'
|
||||
|
||||
const WORKSPACE_ON_OTHER_RUNTIME_MESSAGE =
|
||||
'This workspace runs on another Orca server. Pair that server directly to see its agents.'
|
||||
|
||||
/** The paired host does not own this workspace, so its agents are unverifiable from here. */
|
||||
export class MobileWorkspaceOnOtherRuntimeError extends Error {
|
||||
constructor() {
|
||||
super(WORKSPACE_ON_OTHER_RUNTIME_MESSAGE)
|
||||
this.name = 'MobileWorkspaceOnOtherRuntimeError'
|
||||
}
|
||||
}
|
||||
|
||||
/** What a launch in this workspace can choose from: the host's settings, the agents detected on
|
||||
* the workspace's execution host, and the workspace's repo (absent for the floating workspace). */
|
||||
export type MobileAgentLaunchContext = {
|
||||
@@ -23,14 +38,26 @@ export type MobileAgentLaunchContext = {
|
||||
repo: MobileRuntimeRepoSummary | null
|
||||
}
|
||||
|
||||
export async function loadMobileAgentLaunchContext(args: {
|
||||
export function hostRefusesOtherRuntimeWorkspace(
|
||||
hostCapabilities: readonly string[] | null | undefined
|
||||
): boolean {
|
||||
return hostCapabilities?.includes(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
type MobileAgentLaunchContextArgs = {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
}): Promise<MobileAgentLaunchContext> {
|
||||
/** Whether the host refuses, rather than answers for, a workspace another runtime owns. */
|
||||
hostRefusesOtherRuntime?: boolean
|
||||
}
|
||||
|
||||
export async function loadMobileAgentLaunchContext(
|
||||
args: MobileAgentLaunchContextArgs
|
||||
): Promise<MobileAgentLaunchContext> {
|
||||
const { client, worktreeId } = args
|
||||
// Started before the settings read, not inside the array: the detection request goes on the wire
|
||||
// first, and the recorded sender order is what says so.
|
||||
const detectedAgentsRequest = loadDetectedAgents(client, worktreeId)
|
||||
const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args.hostRefusesOtherRuntime)
|
||||
const [settingsResponse, detectedAgents] = await Promise.all([
|
||||
newTabSettingsRead.request(client),
|
||||
detectedAgentsRequest
|
||||
@@ -38,14 +65,13 @@ export async function loadMobileAgentLaunchContext(args: {
|
||||
const readSettings = newTabSettingsRead.interpret(settingsResponse)
|
||||
// Interpreted after the group, not inside it: whichever peer failed first must not decide the
|
||||
// error the sheet shows, and main raised the detection refusal only once settings had settled.
|
||||
const detected = detectedAgents.interpret(detectedAgents.reply)
|
||||
const detected = interpretDetectedAgents(detectedAgents)
|
||||
return { settings: readSettings(), detectedAgents: detected, repo: detectedAgents.repo }
|
||||
}
|
||||
|
||||
export async function loadMobileNewTabAgentOptions(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
}): Promise<MobileNewTabAgentOption[]> {
|
||||
export async function loadMobileNewTabAgentOptions(
|
||||
args: MobileAgentLaunchContextArgs
|
||||
): Promise<MobileNewTabAgentOption[]> {
|
||||
const context = await loadMobileAgentLaunchContext(args)
|
||||
return buildMobileNewTabAgentOptions(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
@@ -63,7 +89,8 @@ type DetectedAgentsReply = {
|
||||
|
||||
async function loadDetectedAgents(
|
||||
client: RpcClient,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
hostRefusesOtherRuntime = false
|
||||
): Promise<DetectedAgentsReply> {
|
||||
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
|
||||
if (isFloatingWorkspaceWorktreeId(worktreeId)) {
|
||||
@@ -81,6 +108,22 @@ async function loadDetectedAgents(
|
||||
if (!repo) {
|
||||
throw new Error('worktree_repo_not_found')
|
||||
}
|
||||
// A prefix test, not parseExecutionHostId: importing execution-host here splits a web bundle chunk.
|
||||
const runtimeOwned = repos
|
||||
.filter((candidate) => candidate.id === repoId)
|
||||
.map((candidate) => candidate.executionHostId?.startsWith('runtime:') === true)
|
||||
if (runtimeOwned.includes(true)) {
|
||||
// Why: rows on several hosts can share a repo id, and then only a host that refuses another
|
||||
// runtime's workspace may decide; the first row's connection could name this host's SSH target.
|
||||
if (!hostRefusesOtherRuntime || !runtimeOwned.includes(false)) {
|
||||
throw new MobileWorkspaceOnOtherRuntimeError()
|
||||
}
|
||||
return {
|
||||
reply: await preflightDetectAgentsRead.request(client, { worktreeId }),
|
||||
interpret: preflightDetectAgentsRead.interpret,
|
||||
repo
|
||||
}
|
||||
}
|
||||
const connectionId = repo.connectionId?.trim() || null
|
||||
return connectionId
|
||||
? {
|
||||
@@ -96,3 +139,15 @@ async function loadDetectedAgents(
|
||||
repo
|
||||
}
|
||||
}
|
||||
|
||||
function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] {
|
||||
try {
|
||||
return detected.interpret(detected.reply)
|
||||
} catch (error) {
|
||||
// A host with the refusal answers it for a workspace another runtime owns.
|
||||
if (error instanceof Error && error.message === WORKSPACE_ON_OTHER_RUNTIME) {
|
||||
throw new MobileWorkspaceOnOtherRuntimeError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ export const sessionTerminalListRead = bindDeferredRpcOperation(
|
||||
})
|
||||
)
|
||||
|
||||
export type MobileRuntimeRepoSummary = { id: string; connectionId?: string | null }
|
||||
export type MobileRuntimeRepoSummary = {
|
||||
id: string
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
}
|
||||
|
||||
const repoListReader = rpcResultVariant('runtime-repo-list', runtimeRepoListSchema)
|
||||
|
||||
|
||||
@@ -144,7 +144,8 @@ export type DirtyMarkdownDraft = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error'
|
||||
/** `other-runtime`: another Orca server owns the workspace, so the paired host cannot list its agents. */
|
||||
export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' | 'other-runtime'
|
||||
|
||||
export type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
launchAgentInExistingWorkspace,
|
||||
supportsMobileExistingAgentLaunch
|
||||
} from './mobile-existing-agent-launch'
|
||||
import { loadMobileAgentLaunchContext } from './mobile-new-tab-agent-loader'
|
||||
import {
|
||||
hostRefusesOtherRuntimeWorkspace,
|
||||
loadMobileAgentLaunchContext
|
||||
} from './mobile-new-tab-agent-loader'
|
||||
import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent'
|
||||
|
||||
// Launch path for the phone's AI buttons ("Fix checks with AI", "Resolve conflicts with AI", commit
|
||||
@@ -38,7 +41,11 @@ export async function launchAgentWithPrompt(args: {
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveMobileSourceControlLaunchAgent(
|
||||
await loadMobileAgentLaunchContext({ client: args.client, worktreeId: args.worktreeId }),
|
||||
await loadMobileAgentLaunchContext({
|
||||
client: args.client,
|
||||
worktreeId: args.worktreeId,
|
||||
hostRefusesOtherRuntime: hostRefusesOtherRuntimeWorkspace(args.hostCapabilities)
|
||||
}),
|
||||
args.actionId
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useEffect } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import { useClipboardReader } from '../platform/clipboard'
|
||||
import { triggerSelection, triggerError } from '../platform/haptics'
|
||||
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
|
||||
import {
|
||||
hostRefusesOtherRuntimeWorkspace,
|
||||
loadMobileNewTabAgentOptions,
|
||||
MobileWorkspaceOnOtherRuntimeError
|
||||
} from './mobile-new-tab-agent-loader'
|
||||
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
|
||||
import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate'
|
||||
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
|
||||
@@ -13,6 +17,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
worktreeId,
|
||||
client,
|
||||
connState,
|
||||
hostCapabilities,
|
||||
activeHandle,
|
||||
pendingDiffNotesDelivery,
|
||||
showCreateTabDrawer,
|
||||
@@ -120,6 +125,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
}
|
||||
}, [clipboardContents, selectModeActive, setCanPaste])
|
||||
|
||||
const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(hostCapabilities)
|
||||
useEffect(() => {
|
||||
const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null
|
||||
if (!shouldLoadAgentOptions) {
|
||||
@@ -140,24 +146,34 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
void (async () => {
|
||||
const options = await loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId
|
||||
worktreeId,
|
||||
hostRefusesOtherRuntime
|
||||
})
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setCreateTabAgentOptions(options)
|
||||
setCreateTabAgentLoadState('loaded')
|
||||
})().catch(() => {
|
||||
})().catch((error: unknown) => {
|
||||
if (!stale) {
|
||||
setCreateTabAgentOptions([])
|
||||
setCreateTabAgentLoadState('error')
|
||||
setCreateTabAgentLoadState(
|
||||
error instanceof MobileWorkspaceOnOtherRuntimeError ? 'other-runtime' : 'error'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
hostRefusesOtherRuntime,
|
||||
pendingDiffNotesDelivery,
|
||||
showCreateTabDrawer,
|
||||
worktreeId
|
||||
])
|
||||
return {
|
||||
handlePaste,
|
||||
flushPendingLiveInputBeforeAttachmentSend,
|
||||
|
||||
@@ -37,6 +37,13 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
|
||||
sendDiffNotesToNewAgent,
|
||||
handleCreateTerminal
|
||||
} = scope
|
||||
const otherRuntimeAgentRow = {
|
||||
label: 'Agents on Another Server',
|
||||
hint: 'Pair that server directly',
|
||||
icon: Bot,
|
||||
disabled: true,
|
||||
onPress: () => {}
|
||||
}
|
||||
const createTabAgentActions =
|
||||
createTabAgentLoadState === 'loading'
|
||||
? [
|
||||
@@ -66,17 +73,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: createTabAgentLoadState === 'error'
|
||||
? [
|
||||
{
|
||||
label: 'Agent Presets Unavailable',
|
||||
hint: 'Check the host connection',
|
||||
icon: Bot,
|
||||
disabled: true,
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: []
|
||||
: createTabAgentLoadState === 'other-runtime'
|
||||
? [otherRuntimeAgentRow]
|
||||
: createTabAgentLoadState === 'error'
|
||||
? [
|
||||
{
|
||||
label: 'Agent Presets Unavailable',
|
||||
hint: 'Check the host connection',
|
||||
icon: Bot,
|
||||
disabled: true,
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: []
|
||||
const sendDiffNotesAgentActions =
|
||||
pendingDiffNotesDelivery === null
|
||||
? []
|
||||
@@ -118,17 +127,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: createTabAgentLoadState === 'error'
|
||||
? [
|
||||
{
|
||||
label: 'Agent Presets Unavailable',
|
||||
hint: 'Copy notes instead',
|
||||
icon: Bot,
|
||||
disabled: true,
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: []
|
||||
: createTabAgentLoadState === 'other-runtime'
|
||||
? [otherRuntimeAgentRow]
|
||||
: createTabAgentLoadState === 'error'
|
||||
? [
|
||||
{
|
||||
label: 'Agent Presets Unavailable',
|
||||
hint: 'Copy notes instead',
|
||||
icon: Bot,
|
||||
disabled: true,
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
// Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push.
|
||||
const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
|
||||
@@ -10,6 +10,11 @@ import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import {
|
||||
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
|
||||
RUNTIME_CAPABILITIES,
|
||||
WORKSPACE_ON_OTHER_RUNTIME
|
||||
} from '../../shared/protocol-version'
|
||||
import {
|
||||
resolveWorkspaceAgentDetectionHost,
|
||||
type WorkspaceAgentDetectionStore
|
||||
@@ -245,6 +250,25 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => {
|
||||
'worktree_execution_host_unresolved'
|
||||
)
|
||||
})
|
||||
it('#13752: refuses a worktree or folder another runtime owns instead of probing here', () => {
|
||||
const s = store({
|
||||
repos: [
|
||||
repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' }),
|
||||
repo({
|
||||
id: 'r2',
|
||||
path: '/srv/r2',
|
||||
executionHostId: 'runtime:env-b',
|
||||
connectionId: 'b-ssh',
|
||||
projectGroupId: 'g1'
|
||||
})
|
||||
],
|
||||
projectGroups: [GROUP],
|
||||
folderWorkspaces: [{ ...FOLDER, folderPath: '/srv', executionHostId: 'runtime:env-b' }]
|
||||
})
|
||||
expect(() => resolveFor(s, 'r1::/srv/r1')).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
expect(() => resolveFor(s, 'r2::/srv/r2')).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
expect(() => resolveFor(s, folderWorkspaceKey('f1'))).toThrow(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preflight.detectAgents resolves the workspace on the host', () => {
|
||||
@@ -296,4 +320,20 @@ describe('preflight.detectAgents resolves the workspace on the host', () => {
|
||||
})
|
||||
expect(refreshMock).toHaveBeenCalledWith(wslContext('Ubuntu', 'p1', 'global-default'))
|
||||
})
|
||||
|
||||
it('#13752: a paired client naming a workspace another runtime owns gets a refusal', async () => {
|
||||
const dispatcher = dispatcherFor(
|
||||
store({ repos: [repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' })] })
|
||||
)
|
||||
const response = await dispatcher.dispatch({
|
||||
id: '1',
|
||||
authToken: 't',
|
||||
method: 'preflight.detectAgents',
|
||||
params: { worktreeId: 'r1::/srv/r1' }
|
||||
})
|
||||
expect(detectInstalledMock).not.toHaveBeenCalled()
|
||||
expect(response).toMatchObject({ ok: false, error: { message: WORKSPACE_ON_OTHER_RUNTIME } })
|
||||
// Clients let a host decide a shared repo id only when it advertises this refusal.
|
||||
expect(RUNTIME_CAPABILITIES).toContain(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getSshTargetIdForExecutionHost,
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
findFolderWorkspaceCandidateRepos,
|
||||
@@ -86,7 +88,10 @@ function resolveWorktree(
|
||||
if (sshTargetId) {
|
||||
return { kind: 'ssh', connectionId: sshTargetId }
|
||||
}
|
||||
// A worktree another runtime owns is not this host's to probe; keep the host default.
|
||||
// Why refuse: another runtime's worktree is not this host's to probe, and its list would mislead.
|
||||
if (parseExecutionHostId(routing.hostId)?.kind === 'runtime') {
|
||||
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
}
|
||||
return routing.hostId === LOCAL_EXECUTION_HOST_ID && routing.repo
|
||||
? { kind: 'local', repo: routing.repo, path: worktree.path }
|
||||
: null
|
||||
@@ -108,6 +113,9 @@ function resolveFolderWorkspace(
|
||||
if (host.kind === 'ambiguous') {
|
||||
throw new Error('worktree_execution_host_unresolved')
|
||||
}
|
||||
if (host.kind === 'runtime') {
|
||||
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
}
|
||||
const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
||||
const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId)
|
||||
// Why one candidate: a folder spanning several repos has no single project runtime to pick.
|
||||
|
||||
@@ -324,6 +324,12 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY =
|
||||
// must not read that answer as the workspace's (a WSL project on a Windows host differs).
|
||||
export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY =
|
||||
'preflight.workspace-scoped.v1' as const
|
||||
// Why: an older host answers its own agents for a workspace another runtime owns; one with this
|
||||
// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id.
|
||||
export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY =
|
||||
'preflight.other-runtime-refusal.v1' as const
|
||||
/** What such a host answers, instead of probing itself, for another runtime's workspace. */
|
||||
export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime'
|
||||
// Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act
|
||||
// only on the endpoint itself, never on a workspace's SSH host.
|
||||
export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY =
|
||||
@@ -434,6 +440,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
...AUTOMATION_RUNTIME_CAPABILITIES,
|
||||
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
|
||||
PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY,
|
||||
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
|
||||
WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY,
|
||||
...AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user