mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(mobile): key the New Workspace SSH gate by the host holding the target
The desktop and a server can each have an SSH target with the same id. The gate's state, Connect progress and detected agents were keyed by that id alone, so switching to the server's target showed the desktop's "connected" until the server answered, and let create through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -4,25 +4,12 @@
|
||||
"family": "components.execution-target-local",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"1e4520fe6576": {
|
||||
"305921cd9555": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"986a776213e8": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
@@ -63,6 +50,19 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b7f951970a65": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -115,7 +115,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "1e4520fe6576",
|
||||
"state": "305921cd9555",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -127,7 +127,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "986a776213e8",
|
||||
"state": "b7f951970a65",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
+43
-43
@@ -4,21 +4,6 @@
|
||||
"family": "components.execution-target-local",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"1e4520fe6576": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2498f3d3369f": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -51,10 +36,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"365a6864b043": {
|
||||
"detected": [],
|
||||
"305921cd9555": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
@@ -136,6 +123,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"5990695f92a7": {
|
||||
"detected": [],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"74def3993a7c": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -272,19 +272,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"986a776213e8": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"9b8ae0c8eec5": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 2,
|
||||
@@ -347,6 +334,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"b7f951970a65": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db1ccb3eedf8": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -468,7 +468,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "1e4520fe6576",
|
||||
"state": "305921cd9555",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -480,7 +480,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "986a776213e8",
|
||||
"state": "b7f951970a65",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -492,7 +492,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -504,7 +504,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -516,7 +516,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -528,7 +528,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -540,7 +540,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -552,7 +552,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -564,7 +564,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -576,7 +576,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -588,7 +588,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -600,7 +600,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "5990695f92a7",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +490,64 @@ describe('NewWorktreeModal project targets', () => {
|
||||
expect(desktopMethods).not.toContain('worktree.create')
|
||||
})
|
||||
|
||||
it("never shows the desktop's SSH state for a server target with the same id", async () => {
|
||||
const sshRepo = { ...repos[0]!, id: 'ssh-repo', path: '/home/dev/orca', connectionId: 'devbox' }
|
||||
const desktop = vi.fn((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [sshRepo] } })
|
||||
}
|
||||
if (method === 'ssh.getState') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
result: {
|
||||
state: { targetId: 'devbox', status: 'connected', error: null, reconnectAttempt: 0 }
|
||||
}
|
||||
})
|
||||
}
|
||||
if (method === 'preflight.detectRemoteAgents') {
|
||||
return Promise.resolve({ ok: true, result: ['codex'] })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
// The server has not answered yet, so it has no state to show.
|
||||
const server = vi.fn((method: string) =>
|
||||
method === 'repo.list'
|
||||
? Promise.resolve({ ok: true, result: { repos: [{ ...sshRepo, path: '/srv/orca' }] } })
|
||||
: new Promise(() => {})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client: requestsOnly(desktop),
|
||||
serverClients: new Map([['runtime:env-1', requestsOnly(server)]]),
|
||||
hostId: 'host-same-ssh-id',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
const gate = () => renderer.root.find((node) => node.props.sshGate !== undefined).props.sshGate
|
||||
const picker = runTargetPicker(renderer)
|
||||
expect(picker.props.items.map((item: { id: string }) => item.id)).toEqual([
|
||||
'ssh:devbox',
|
||||
'runtime:env-1'
|
||||
])
|
||||
await act(async () => picker.props.onSelect(picker.props.items[0]))
|
||||
await flushUpdates()
|
||||
expect(gate().status).toBe('connected')
|
||||
|
||||
await act(async () => runTargetPicker(renderer).props.onSelect(picker.props.items[1]))
|
||||
await flushUpdates()
|
||||
|
||||
expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1')
|
||||
expect(server).toHaveBeenCalledWith('ssh.getState', { targetId: 'devbox' })
|
||||
expect(gate()).toMatchObject({ status: null, requiresConnection: true })
|
||||
expect(server).not.toHaveBeenCalledWith('worktree.create', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it("lists the desktop's repos at once while a server has not answered", async () => {
|
||||
const desktop = vi.fn((method: string) =>
|
||||
method === 'repo.list'
|
||||
|
||||
@@ -99,6 +99,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
const executionTarget = useNewWorkspaceExecutionTarget({
|
||||
client,
|
||||
connectionId: selectedRepoConnectionId,
|
||||
server: repoServer,
|
||||
visible
|
||||
})
|
||||
const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
localAgentDetectionRead,
|
||||
@@ -13,10 +14,15 @@ import {
|
||||
} from '../tasks/workspace-ssh-gate'
|
||||
|
||||
type DetectedAgentIdsState = {
|
||||
connectionId: string | null
|
||||
target: string
|
||||
ids: Set<string>
|
||||
}
|
||||
|
||||
type SshStateEntry = {
|
||||
target: string
|
||||
record: WorkspaceSshRecord
|
||||
}
|
||||
|
||||
function fallbackSshState(
|
||||
targetId: string,
|
||||
status: WorkspaceSshRecord['status'],
|
||||
@@ -28,25 +34,30 @@ function fallbackSshState(
|
||||
export function useNewWorkspaceExecutionTarget(args: {
|
||||
client: RpcClient | null
|
||||
connectionId: string | null
|
||||
/** The server the repo runs on, which holds its SSH target; undefined for the desktop's own. */
|
||||
server?: ExecutionHostId
|
||||
visible: boolean
|
||||
}): {
|
||||
sshGate: WorkspaceSshGate
|
||||
detectedAgentIds: Set<string> | null
|
||||
connect: () => Promise<void>
|
||||
} {
|
||||
const { client, connectionId, visible } = args
|
||||
const [sshState, setSshState] = useState<WorkspaceSshRecord | null>(null)
|
||||
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(null)
|
||||
const { client, connectionId, server, visible } = args
|
||||
// Why the host too: the desktop and a server can each hold a target with the same id.
|
||||
const target = JSON.stringify([server ?? null, connectionId])
|
||||
const [sshState, setSshEntry] = useState<SshStateEntry | null>(null)
|
||||
const setSshState = (record: WorkspaceSshRecord) => setSshEntry({ target, record })
|
||||
const [connectingTarget, setConnectingTarget] = useState<string | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
null
|
||||
)
|
||||
const sshGate = deriveWorkspaceSshGate({
|
||||
connectionId,
|
||||
state: sshState,
|
||||
connecting: connectingTargetId === connectionId
|
||||
state: sshState?.target === target ? sshState.record : null,
|
||||
connecting: connectingTarget === target
|
||||
})
|
||||
const detectedAgentIds =
|
||||
detectedAgentIdsState?.connectionId === connectionId &&
|
||||
detectedAgentIdsState?.target === target &&
|
||||
(connectionId === null || sshGate.status === 'connected')
|
||||
? detectedAgentIdsState.ids
|
||||
: null
|
||||
@@ -79,7 +90,7 @@ export function useNewWorkspaceExecutionTarget(args: {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, visible])
|
||||
}, [client, target, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || (connectionId && sshGate.status !== 'connected')) {
|
||||
@@ -95,26 +106,26 @@ export function useNewWorkspaceExecutionTarget(args: {
|
||||
: localAgentDetectionRead.interpret(await localAgentDetectionRead.request(client))
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({
|
||||
connectionId,
|
||||
target,
|
||||
ids: detected.accepted ? new Set(detected.value) : new Set()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({ connectionId, ids: new Set() })
|
||||
setDetectedAgentIdsState({ target, ids: new Set() })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, sshGate.status, visible])
|
||||
}, [client, target, sshGate.status, visible])
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (!client || !connectionId) {
|
||||
return
|
||||
}
|
||||
setConnectingTargetId(connectionId)
|
||||
setConnectingTarget(target)
|
||||
setSshState(fallbackSshState(connectionId, 'connecting', null))
|
||||
try {
|
||||
const reply = await sshRepoConnectRun.request(
|
||||
@@ -133,7 +144,7 @@ export function useNewWorkspaceExecutionTarget(args: {
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setConnectingTargetId((current) => (current === connectionId ? null : current))
|
||||
setConnectingTarget((current) => (current === target ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user