mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(hosts): resolve a folder workspace's SSH host from the repo's host, not its raw connectionId
`resolveFolderWorkspaceHost` inferred a workspace's host by reading
`repo.connectionId` directly. SSH ownership has two spellings on a repo row, and
a row carrying only `executionHostId: 'ssh:<target>'` has no `connectionId` to
read — so it counted as a local repo and the workspace resolved `{ kind: 'local' }`.
That is an execute-here answer for a workspace whose files are on an SSH host,
the #11163 class, and it fires on a well-formed row.
Resolve the host first, then read the target off it. Every other row keeps its
existing contribution, including a `runtime:` row's nested SSH target: that
target is not this client's to dial, but narrowing it here would be a second
behaviour change riding on this one. The runtime branch above still answers
`local`, and now says so — `FolderWorkspaceHost` has no runtime variant, and
widening the type is its own change, not an oversight to be silently corrected.
Three smaller items that stand on their own:
- `resolveWorktreeExecutionHost` gains a `malformed` reason distinct from
`unknown`. `unknown` (nothing carries the id) is a verdict the launch path may
legitimately dispose of as a plain local folder; `malformed` (the row named a
host that cannot be parsed) must fail closed. One word for two situations is
the shape that lost the distinction in #18006. The strict read is private to
that module: `getRepoExecutionHostId` stays the answer everywhere else, since
its fall-through to `local` is harmless for the grouping, label and index
callers that are nearly all of its ~340 call sites.
- `readAllWorktreeMetaForRepo` / `readWorktreeMetaForRepo` replace four
open-coded copies of the same host-qualified read (the F7/F8 lockstep shape).
- `getExecutionHostLabel` answers 'Unknown host' rather than 'All hosts' for an
id that names no host. Showing one unroutable row as though it were on every
host is wrong on its own terms. Plain English like every other label in that
module, none of which resolve through the renderer's i18n catalog.
* fix(hosts): resolve the host in candidate selection too, not just in resolution
The first pass fixed how a repo row is classified once it reaches
`resolveFolderWorkspaceHost`. The candidate filter decides which rows reach it at
all, and it read `repo.connectionId` raw as well — so an SSH-only row outside the
project-group subtree was dropped before the new logic could see it, and the
execute-here bug survived for the population the fix was for, via a different
path. Found in review by CodeRabbit.
Three repo-row reads had the same root cause, not one:
- the scope-connection filter, comparing a path repo's raw field against the
workspace/group connection;
- the group-connection set, built from group repos' raw fields;
- that set's membership test against path repos' raw fields.
The last two are one comparison with the mismatch on either side, so resolving
only the path side would have reintroduced it from the other direction.
All three, plus the resolution loop, now go through one `getRepoScopeConnectionId`
helper. Non-SSH hosts still fall back to the raw field, so a `runtime:` row keeps
contributing its nested target exactly as before.
The new tests use a repo matched only by path, outside the subtree — the
population every existing test missed, which is why four passing revert-tests
did not catch this. One of them is labelled as pinning the resolver rather than
the filter: under the old raw read both rows came back connectionless and matched
each other by accident, so it survives a filter revert and must not be counted as
coverage for it.
164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
/**
|
|
* Resolves which host a folder workspace executes on.
|
|
*
|
|
* Main and the renderer both have to answer this — the list projects a record's
|
|
* host from it, dispatch targets one from it — so the answer lives here rather
|
|
* than being re-derived per side and drifting. The workspace's own
|
|
* `executionHostId` pin wins: a pinned workspace runs there whatever its repos
|
|
* say, which is exactly the case that used to read as local.
|
|
*
|
|
* `ambiguous` is a distinct answer, not a missing one: a scope that spans a
|
|
* local repo and an SSH one has no single host, and callers must fail closed
|
|
* with something visible rather than defaulting to local.
|
|
*/
|
|
|
|
import type { FolderWorkspace } from './folder-workspace-types'
|
|
import type { ProjectGroup } from './project-group-types'
|
|
import type { Repo } from './repo-types'
|
|
import { isPathInsideOrEqual } from './cross-platform-path'
|
|
import { getProjectGroupSubtreeIds } from './project-groups'
|
|
import { getRepoExecutionHostId, parseExecutionHostId } from './execution-host'
|
|
|
|
export type FolderWorkspaceHostState = {
|
|
folderWorkspaces: readonly FolderWorkspace[]
|
|
projectGroups: readonly ProjectGroup[]
|
|
repos: readonly Repo[]
|
|
}
|
|
|
|
export type FolderWorkspaceHost =
|
|
| { kind: 'missing' }
|
|
| { kind: 'local' }
|
|
| { kind: 'ssh'; targetId: string }
|
|
| { kind: 'ambiguous' }
|
|
|
|
/** Reads a stored connection id: blank is local, since no write path normalizes it. */
|
|
export function normalizeConnectionId(value: string | null | undefined): string | null {
|
|
return value?.trim() || null
|
|
}
|
|
|
|
/**
|
|
* The SSH target whose filesystem holds this repo's files, or `null` for anything else.
|
|
*
|
|
* SSH ownership has two spellings on a repo row — the legacy `connectionId` field and the unified
|
|
* `executionHostId` — so reading the raw field sees only one of them and a row carrying only
|
|
* `executionHostId: 'ssh:<target>'` reads as if it had no connection at all. Every comparison in
|
|
* this file goes through here: the candidate filters decide which rows reach the resolver, so
|
|
* reading raw in either place drops the row before the resolver can classify it.
|
|
*
|
|
* A non-SSH host falls back to the raw field so a `runtime:` row keeps contributing its nested
|
|
* target exactly as it does today. That target is not this client's to dial, but changing it is a
|
|
* separate defect with its own reasoning — see the note in `resolveFolderWorkspaceHost`.
|
|
*/
|
|
function getRepoScopeConnectionId(repo: Repo): string | null {
|
|
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
|
return host?.kind === 'ssh' ? host.targetId : normalizeConnectionId(repo.connectionId)
|
|
}
|
|
|
|
function getFolderScopeCandidateRepos(args: {
|
|
folderPath: string
|
|
projectGroupId: string
|
|
connectionId: string | null
|
|
projectGroups: readonly ProjectGroup[]
|
|
repos: readonly Repo[]
|
|
}): Repo[] {
|
|
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
|
// Classify each repo once. The previous pair of filters read every
|
|
// projectGroupId twice before applying the same path predicate.
|
|
const groupRepos: Repo[] = []
|
|
const pathRepos: Repo[] = []
|
|
for (const repo of args.repos) {
|
|
const projectGroupId = repo.projectGroupId
|
|
if (typeof projectGroupId === 'string' && groupIds.has(projectGroupId)) {
|
|
groupRepos.push(repo)
|
|
} else if (isPathInsideOrEqual(args.folderPath, repo.path)) {
|
|
pathRepos.push(repo)
|
|
}
|
|
}
|
|
if (args.connectionId) {
|
|
return [
|
|
...groupRepos,
|
|
...pathRepos.filter((repo) => getRepoScopeConnectionId(repo) === args.connectionId)
|
|
]
|
|
}
|
|
if (groupRepos.length === 0) {
|
|
return pathRepos
|
|
}
|
|
// Both sides resolved: comparing a resolved path repo against a raw group read would reintroduce
|
|
// the same mismatch from the other direction.
|
|
const groupConnectionIds = new Set(groupRepos.map(getRepoScopeConnectionId))
|
|
return [
|
|
...groupRepos,
|
|
...pathRepos.filter((repo) => groupConnectionIds.has(getRepoScopeConnectionId(repo)))
|
|
]
|
|
}
|
|
|
|
export function findFolderWorkspaceCandidateRepos(
|
|
state: FolderWorkspaceHostState,
|
|
folderWorkspaceId: string
|
|
): Repo[] {
|
|
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
|
if (!workspace) {
|
|
return []
|
|
}
|
|
const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)
|
|
return getFolderScopeCandidateRepos({
|
|
folderPath: workspace.folderPath,
|
|
projectGroupId: workspace.projectGroupId,
|
|
connectionId: normalizeConnectionId(workspace.connectionId ?? group?.connectionId),
|
|
projectGroups: state.projectGroups,
|
|
repos: state.repos
|
|
})
|
|
}
|
|
|
|
export function resolveFolderWorkspaceHost(
|
|
state: FolderWorkspaceHostState,
|
|
folderWorkspaceId: string
|
|
): FolderWorkspaceHost {
|
|
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
|
if (!workspace) {
|
|
return { kind: 'missing' }
|
|
}
|
|
const explicitHost = parseExecutionHostId(workspace.executionHostId)
|
|
if (explicitHost) {
|
|
// A `runtime:` workspace deliberately answers `local`, and `FolderWorkspaceHost` has no runtime
|
|
// variant to answer with instead. That omission is known: a runtime environment's own server
|
|
// normalizes its work to `local`, and the nested SSH target on such a row is addressable only as
|
|
// the pair (environmentId, targetId) — handing it to this client's SSH table would dial a
|
|
// same-named box in the wrong namespace. Widening the type is its own change, not an oversight
|
|
// here.
|
|
return explicitHost.kind === 'ssh'
|
|
? { kind: 'ssh', targetId: explicitHost.targetId }
|
|
: { kind: 'local' }
|
|
}
|
|
const scopeConnectionId = normalizeConnectionId(
|
|
workspace.connectionId ??
|
|
state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId
|
|
)
|
|
const candidateRepos = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId)
|
|
let hasLocalRepo = false
|
|
const connectionIds = new Set<string>()
|
|
for (const repo of candidateRepos) {
|
|
const connectionId = getRepoScopeConnectionId(repo)
|
|
if (connectionId) {
|
|
connectionIds.add(connectionId)
|
|
} else {
|
|
hasLocalRepo = true
|
|
}
|
|
}
|
|
if (scopeConnectionId) {
|
|
const hasDifferentSshConnection = [...connectionIds].some(
|
|
(connectionId) => connectionId !== scopeConnectionId
|
|
)
|
|
return hasLocalRepo || hasDifferentSshConnection
|
|
? { kind: 'ambiguous' }
|
|
: { kind: 'ssh', targetId: scopeConnectionId }
|
|
}
|
|
if (candidateRepos.length === 0 || connectionIds.size === 0) {
|
|
return { kind: 'local' }
|
|
}
|
|
if (hasLocalRepo || connectionIds.size > 1) {
|
|
return { kind: 'ambiguous' }
|
|
}
|
|
return { kind: 'ssh', targetId: [...connectionIds][0] }
|
|
}
|