Files
orca/src/main/runtime/runtime-workspace-session-controller.ts
T
Neil c61ca56a9b fix(ssh): resolve the worktree's execution host instead of guessing from one repo row (#17909)
* fix(host-routing): resolve the execution host before reading a connection

Three issues in one defect class: a resolver reads one spelling of one
arbitrarily chosen row instead of resolving the worktree's execution host,
so something local answers a question about a remote.

returned that row's connectionId. With duplicate repo rows for one repo id
it could pair a runtime owner with a client-owned SSH connection. It now
resolves through the same ambiguity-aware index getRuntimeEnvironmentIdForWorktree
uses, prefers the repo row for the host the worktree names, and derives the
connection from the resolved host. Conflicting rows return `undefined`
(this module's documented "cannot determine the host"), never `null`.

`store.getRepo(worktree.repoId)?.connectionId ?? null`. `getRepo` is
host-blind and the same repo id can exist on local, SSH and runtime hosts,
so a remote worktree could spawn its PTY on the client with the remote cwd.
resolveWorktreeLaunchHost picks the row for the worktree's host and reads
the connection off that host; conflicting rows are unresolved, not local.

session-partition owner maps that contradict each other. Both now compute
through one shared function whose argument records the divergence. No
behaviour change on either side: converging needs a read-both migration,
since both partitions hold real data written by shipping builds.

* fix(host-routing): keep nested SSH connections resolvable under a runtime host

getRepoSshConnectionId read only the resolved execution host, so a repo row
owned by a runtime that reaches a nested SSH target (connectionId: ssh-*,
executionHostId: runtime:*) resolved to no connection — answering 'local' for
a remote worktree, the same defect #17909 fixed in the other direction.

* fix(host-routing): resolve both sides of the execution host through one rule

The renderer resolver leaked between two different SSH hosts: a worktree on
`ssh:m4air` whose only indexed repo row belonged to `openclaw` answered
'openclaw', because the host-scoped lookup missing fell through to an id-only
one. Main's resolver, in the same change, answered 'm4air' — two resolvers, one
right and one wrong, on identical input.

Both sides now adapt one shared rule (`worktree-execution-host-resolution.ts`):
the worktree's own host outranks every repo row, and a row on a different host
is never evidence about this one. The renderer's WeakMap index becomes the
memoizing adapter it always was; `resolveWorktreeLaunchHost` becomes main's
mapping of unresolved onto its throw.

Settles the rule the change previously answered two ways.
`getRepoSshConnectionId` and `getSshTargetIdForExecutionHost` disagreed for a
runtime host carrying a nested `connectionId`; they now compose, so the
execution host is the single authority. On a `runtime:*` row that field is a
paired HUB's private SSH target, spread through by `repoWithFetchedOwner` and
unaddressable from this client — the project-first successor of the row nulls it
for exactly that reason. That also fixes the `kind !== 'ssh'` fallback, which
fired for `local`: a row declaring itself local handed out an SSH connection.
2026-09-02 16:41:13 -07:00

146 lines
5.6 KiB
TypeScript

import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { workspaceSessionPartitionHostId } from '../../shared/workspace-session-partition-owner'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import type { RuntimeStore } from './runtime-store-contract'
type RuntimeWorkspaceSessionDependencies = {
getStore: () => RuntimeStore | null
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
hasRuntimeOwnedPtyCandidate: (
session: WorkspaceSessionState,
worktreeId: string,
tabs: WorkspaceSessionState['tabsByWorktree'][string]
) => boolean
}
export class RuntimeWorkspaceSessionController {
constructor(private readonly deps: RuntimeWorkspaceSessionDependencies) {}
tryGetHostId(worktreeId: string): ExecutionHostId | null {
const store = this.deps.getStore()
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const workspace = store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === scope.folderWorkspaceId)
if (!workspace) {
return null
}
// An explicit host is authoritative for folder workspaces. The connection
// id is only a legacy fallback for records written before host ids existed.
if (workspace.executionHostId != null) {
return parseExecutionHostId(workspace.executionHostId)?.id ?? null
}
const connectionId = this.deps.resolveFolderConnectionId(workspace)
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId
const repo = store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId))
// Why: SSH worktrees keep their own `ssh:<targetId>` partition here while the renderer writes
// them to 'local'; the shared owner map records that divergence (#12723).
return repo
? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo), 'host-partition')
: LOCAL_EXECUTION_HOST_ID
}
getHostId(worktreeId: string): ExecutionHostId {
const hostId = this.tryGetHostId(worktreeId)
if (!hostId) {
throw new Error('folder_workspace_not_found')
}
return hostId
}
get(worktreeId: string): WorkspaceSessionState | null {
const hostId = this.tryGetHostId(worktreeId)
return hostId ? (this.deps.getStore()?.getWorkspaceSession?.(hostId) ?? null) : null
}
set(worktreeId: string, session: WorkspaceSessionState): void {
this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId))
}
getKnownWorktreeIds(): Set<string> {
const store = this.deps.getStore()
const repos = store?.getRepos?.() ?? []
const repoIds = new Set(repos.map((repo) => repo.id))
const hostIds = new Set<ExecutionHostId>(['local'])
for (const repo of repos) {
hostIds.add(getRepoExecutionHostId(repo))
}
const worktreeIds = new Set<string>()
for (const hostId of hostIds) {
const session = store?.getWorkspaceSession?.(hostId)
for (const worktreeId of Object.keys(session?.tabsByWorktree ?? {})) {
if (repoIds.has(getRepoIdFromWorktreeId(worktreeId))) {
worktreeIds.add(worktreeId)
}
}
}
return worktreeIds
}
getHydrationTargets(includeAllPersistedWorktrees: boolean): Map<string, WorkspaceSessionState> {
const store = this.deps.getStore()
const repos = store?.getRepos?.() ?? []
const repoHostIdByRepoId = new Map(
repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const)
)
const folderHostIdByWorkspaceId = new Map(
(store?.getFolderWorkspaces?.() ?? []).map((workspace) => {
const explicitHostId =
workspace.executionHostId != null
? (parseExecutionHostId(workspace.executionHostId)?.id ?? null)
: null
const connectionId = explicitHostId ? null : this.deps.resolveFolderConnectionId(workspace)
return [
workspace.id,
explicitHostId ??
(connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID)
] as const
})
)
const hostIds = new Set<ExecutionHostId>(['local'])
for (const repo of repos) {
hostIds.add(getRepoExecutionHostId(repo))
}
for (const hostId of store?.getWorkspaceSessionHostIds?.() ?? []) {
hostIds.add(hostId)
}
const targets = new Map<string, WorkspaceSessionState>()
for (const hostId of hostIds) {
const session = store?.getWorkspaceSession?.(hostId)
if (!session) {
continue
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
const scope = parseWorkspaceKey(worktreeId)
const ownerHostId =
scope?.type === 'folder'
? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null)
: (repoHostIdByRepoId.get(
getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId)
) ?? LOCAL_EXECUTION_HOST_ID)
if (
ownerHostId === hostId &&
(includeAllPersistedWorktrees ||
this.deps.hasRuntimeOwnedPtyCandidate(session, worktreeId, tabs))
) {
targets.set(worktreeId, session)
}
}
}
return targets
}
}