mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* test(mobile): record main's file-preview and markdown-disk-fallback replies
Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.
Three new families, five scenarios, ten goldens:
- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
`files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
`renderer_unavailable` sends the markdown tab down. It carries a second scenario
that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
recorded somewhere in its own family to replay as the `normal` partition.
No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff c2962a765a -- mobile/rpc-foundation/goldens` confirms
as empty.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history
Thirty-five unchecked reply readers across seven files become checked zod readers,
so a malformed host reply surfaces as one readable error at the operation boundary
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only: every good
reply decodes to the same value it did, which the `normal` partition of every
matrix golden holds byte for byte. Nothing on the wire moves — no method, params,
options, timeout or acceptance policy changes at any site.
The inventory drops from 137 readers over 31 files to 102 over 24.
What each domain checks, and what it deliberately does not:
- files/preview — one schema for `files.read` and `files.readTerminalArtifact`, one
for the two preview methods. `content` is required on the text pair because the
markdown disk fallback publishes it into the tab with no guard; the image pair
requires nothing, because normalizeImagePreviewResult guards all four members and
the host's own "binary I cannot preview" and "not actually an image" arms are good
replies the screen renders today.
- files/tab-doc — stricter than the preview screen on the same two methods, because
a tab publishes what it read into a typed ready document with no guard. `git.diff`
reads as two variants, and an arm this build has not heard of takes the binary one
rather than refusing the reply.
- files/explorer — the directory listing is an array and a row needs the name and the
directory flag the tree projection turns on; the legacy capped list needs its rows'
paths and the truncation flag its note draws.
- files/ownership — the two members that decide *where a write lands* are fatal on a
wrong type rather than salvaged, because absence reads as `local` downstream and a
salvage would send a mutation to the wrong host. `hostId`'s absent/null/string
states stay distinct, and the SSH connection generation passes through at its own
type because the mutation echoes it back to the host.
- dictation — the setup the sheet renders is checked; the model rows need the `id`
the sheet keys and sends back. The five sends whose reply body no call site reads
keep an unknown payload, and so does `speech.dictation.finish`, whose transcript is
read past a staleness guard that a reader throw would move the failure across.
- host-screen — the repo catalog, the SSH labels and the host platform. The four
writes read no reply body; `worktree.activate` stays opaque because the session
route's second report site awaits it outside any catch.
- agent-history — the capability gate and both scan containers. The session rows stay
unknown on purpose: `agent` is a vocabulary that grows with every agent CLI Orca
learns to scan and that this client echoes back on resume, so narrowing it would
refuse a newer host's reply or drop the very sessions it added.
Two shared readers were widened to take the strings the reply readers hand them —
`getRepoExecutionHostId` and `buildRepoHostIdByRepoId` — because both already answer
`local` for a host-id spelling they cannot parse, and closing that spelling in a
reply schema would refuse a newer host's own catalog.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus and re-record the checked reply readers
`baseline` moves to this branch's last fenced commit, which is what `--record`
refuses without: main's fenced tree drifted past the session domain's pin when
#21114 and the dependency bump landed, and the product edit in the commit before
this one moves it again.
Every body move is confined to a malformed partition of a family this branch
touched. No `normal` partition moved, which is the byte-for-byte control on good
replies, and no golden outside the seven files' families moved at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the dictation reader requiring a mode main rendered without
The setup sheet's `normal` partition refused after the reader landed, which is the
success control saying the schema was wrong rather than the fixture: `dictationMode`
was declared required because the one unguarded consumer pushes it into a
`useState<'toggle' | 'hold'>` and cannot invent a value, but main rendered a sheet
whose reply omitted it, and requiring a member no consumer crashes on is exactly the
version claim Rule 1 of the remote-wire contract warns about.
The member is salvaged now and keeps its open arm set, so an unknown mode still
degrades to `toggle` rather than to one that matches no segment. The native-chat
refresh spells that same `toggle` for an absent mode, which is the value its state
already started at, and the route parity pins are refreshed for the one literal and
the two callback bodies that moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the dictation fix and re-record
Second repin of the branch: the fix to the setup reader is a fenced-tree change, so
`--record` refuses until `baseline` names it. The speech family's `normal` partition
is back to main's projection, which is what said the first reader was wrong.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mutant evidence for the checked reply readers
Three mutations applied by hand, run, and reverted, recorded beside the adapter
family mutations in the same shape. They are kept in their own file because a reader
mutation is not killed by a pilot scenario: a pilot serves a good reply, and a schema
that has stopped checking a member reads a good reply exactly as before. What kills
them is a matrix golden's malformed partition, the schema's unit pin, or a consumer
pin, and each is named against its mutation.
Two survived their first run, and both survivals were defects in the gates:
- Loosening the file tab's `content` was invisible, because the pin dropped members
only in pairs and each pair is refused by the sibling. The pin now drops exactly
one member per iteration, and the preview text schema and the legacy file list got
the same treatment.
- Collapsing the hostId tri-state was invisible, because no golden serves an explicit
null host — the local ownership scenario omits the member. The ownership test now
captures all three states end to end, which is where a tri-state belongs.
`repo-metadata-platform` is re-anchored where this branch moved the read it mutates:
the hand-rolled `readHostPlatform` became the reply schema's own projection. The
defect it injects is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record main's repo-icon and speech-vocabulary replies
The closed enums this branch introduced had no fixture behind them. `provider`,
`dictationMode` and `repoIcon` were carried by no scenario at all — the fulfilled
repo-metadata golden records `repoIconsByName: []` — so the corpus could not have
moved whatever arm set the schemas declared, which is how a reader can pin a
vocabulary the host does not speak and still decode to a zero-move delta.
Two scenarios, both appended to an existing family so `familyGoldens` adds no
matrix golden, recorded from main's own tree at the pin with no product edit in it:
- `settings-repo-metadata-icons` — all three `RepoIcon` arms, a github-sourced
image with a label, an explicit `badgeColor`, and a mixed-host catalog so the
ssh/settings/platform wave runs too.
- `speech-setup-sheet-model-vocabulary` — `provider` on both arms, `status` on two,
`dictationMode: "hold"`, and null and numeric `sizeBytes`/`progress`.
Control: re-recording the whole corpus at the pin reproduces every committed
golden body, including this branch's five earlier before-pictures; only `baseline`
and the masked `lockfileSha256` move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the repo icon narrowing a member no consumer reads
The image arm of `repoIcon` declared `source` as the four values
`RepoIconImageSource` spells today (src/shared/repo-icon.ts:3). MobileRepoIcon
reads `type`, `src`, `label`, `emoji` and `name`, and never `source`, so the only
thing that enum could do was fail the union arm for a source a later host adds —
dropping the whole icon and drawing the Folder default where main drew the image.
That is the one arm set on this branch whose degrade was not already main's own
behaviour for an unknown value.
Dropping the declaration keeps the member: `looseObject` passes it through
verbatim, so the decoded object is byte-identical to the one main published, which
`settings-repo-metadata-icons` now records.
The two type sites that hold an icon move to the decoded type. A host `RepoIcon`
still satisfies the rendered union, so the worktree rows that carry one are
unaffected.
Every other closed enum on this branch was checked against the host's own shared
type and left alone: speech `provider`/`status`/`dictationMode`
(runtime-worktree-contracts.ts:83/85/86), `groupBy`/`sortBy`
(persisted-ui-state-types.ts:41-42), `platform` (Node's own domain; the handler
answers `process.platform`). For each, a salvaged member lands on the same branch
main's unknown value did: `=== 'openai'` and `=== 'ready'` stay false, a missing
`groupBy` and an unmapped one both answer null, and an unknown platform and a null
one both label the host "This computer".
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the repo-icon fix and re-record
Header-only: all 770 goldens move on `baseline` alone, including the two recorded
from main's tree two commits back. The icon fix and the two new fixtures decode to
the bytes main published.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the repo-metadata readers total the way main's were
readSshTargets and readHostPlatform answered [] and null for any payload at
all. The checked schemas threw for a non-object, and because the label write
runs first in the same sequence that throw also skipped the platform write, so
a malformed reply left both decorative labels at their previous values instead
of degrading. A .catch on each restores main's answer without giving up the
row filter or the checked reader.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forward the dictation mode instead of substituting a default
The reader closed the mode to two arms and the native-chat refresh spelled
`?? 'toggle'`, which is a good-reply change no golden covers: main left the
state undefined for a reply that omits the mode, and undefined binds no press
handler on the terminal input mic. Head gave that mic a working toggle. The
member is forwarded as the string the host sent and the refresh is main's line
again, so an absent or unknown mode leaves the mic exactly as inert as main's.
The route-parity runtime-string pin is main's own sha again.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the review fixes and re-record
The repo-metadata readers are total again, so both families' `result-absent`
and `result-null` checkpoints decode to main's bytes instead of the caught
throw, and the two delta rows they cost go away. The dictation mode forwards
verbatim, which no recorded reply exercises differently.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin onto the merge and re-record
Pins the corpus to the merge commit so main's ten create-terminal goldens and
this branch's own are recorded from one tree.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three reader comments round 2 caught
The ownership schema said an explicit null hostId means the host said local;
the code refuses it, which is the whole reason mutant (c) exists. The AiVault
sessions cast cited a golden whose fixture row carries three members, not the
sixteen the cast claims — the full row is in aivault-history-screen-listed —
and both the issues cast and the schema doc said the rows are rendered when
the only read anywhere is issues.length.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct stale file:line citations in the batch-A reply schemas
Resolved every citation in the seven reply-schema modules and the SAFETY
notes against the tree and diffed each target line against the claim beside
it. Twelve were wrong, two of them past the end of a file that had shrunk,
so they read as evidence while pointing at a closing brace.
- file-explorer: the entries put is :157 not :160, the relativePath split is
file-list-fallback.ts:48 not :42, and the truncated publish is :136 not
:141. buildFileExplorerRows is no symbol at all; the sort-and-walk is
flattenDirectoryCache (file-tree.ts:58).
- file-ownership: the !summary throw is :68 not :64.
- file-preview: the markdown disk fallback reads content at :60 not :65.
- file-tab-doc: the html body render is :68 not :81 and the file arm is
:73-75 not :86-88 (the file has 78 lines); the isImage guard is :58 not
:66; the kind !== 'text' branch is :41 not :44; mobileDiffImageDataUri
spans :22-33 not :20-31; the unguarded content.length is
mobile-diff-lines.ts:35, the function that does it rather than :34.
- agent-history: both members land at :133-135; :135 alone is issues.
- dictation: the parenthetical read as citing the staleness guard when it
named the rpcPayloadMember read. Both are cited now, :237 and :225.
Comments only. No schema, type, or runtime behaviour changes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the unguarded activation report site that pins the opaque schema
Handler audit over all 33 interpret sites in the four domains found one site
that is structurally unguarded: use-mobile-session-startup.ts:170 reports the
activation verdict from inside a fire-and-forget `void (async …)()` whose only
`.catch` sits on the request, not on the chain. A throw there would be an
unhandled rejection and would also skip the terminal fetch below it.
Nothing throws there today, because `worktree.activate` reads
hostScreenUnreadReplySchema, which is `z.unknown()`. That totality is load
bearing rather than incidental, so the doc now names the line it protects and
contrasts it with the first report site at :141, which is chained
`.then(…).catch(…)` and would survive a throw.
Comments only.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a bound descriptor's interpret survives being detached
bindDeferredRpcOperation builds interpret as a shorthand method closing over the
captured operation, never `this`, which is what lets eleven call sites pass it as
a bare function reference. Nothing named that invariant.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): repin the RPC recording baseline to the main merge
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin the closed reply enums to the host unions where tsc looks
pullfrog: the PR body promised a Record<HostUnion, true> pin for every
closed enum in this batch and the code had none. Adding them in the
schema tests would have changed nothing: mobile/tsconfig.json excludes
*.test.ts, so a coverage record there is never typechecked (a mutation
that dropped a key stayed green).
hostUnionArms(coverage) in zod-salvage spells the arm list as a
Readonly<Record<U, true>> in the schema module itself, called with the
host union as the explicit type argument: an arm the host adds is a
missing property, one it drops is an excess property. Used for the speech
provider and status (RuntimeSpeechModelSummary), the workspace groupBy and
sortBy (PersistedUIState) and Node's platform list, which host-screen now
imports from mobile-runtime-host-platform instead of duplicating. The repo
icon branches satisfy Readonly<Record<RepoIcon['type'], z.ZodType>>.
Three mutations (drop `manual`, add `bogus`, drop the image branch) each
fail tsc. The tests iterate the exported lists; the platform mutant is
re-anchored to the renamed constant.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
import type { GlobalSettings } from './global-settings-types'
|
|
import type { Repo } from './repo-types'
|
|
import type { Worktree } from './worktree/types'
|
|
|
|
export const LOCAL_EXECUTION_HOST_ID = 'local'
|
|
export const ALL_EXECUTION_HOSTS_SCOPE = 'all'
|
|
|
|
export type ExecutionHostKind = 'local' | 'ssh' | 'runtime'
|
|
export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}`
|
|
|
|
export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId
|
|
|
|
export type ParsedExecutionHost =
|
|
| { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID }
|
|
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
|
|
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
|
|
|
|
function getCurrentLocalPlatform(): NodeJS.Platform | null {
|
|
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
|
|
.navigator
|
|
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
|
|
if (/Windows/i.test(userAgent)) {
|
|
return 'win32'
|
|
}
|
|
if (/Mac/i.test(userAgent)) {
|
|
return 'darwin'
|
|
}
|
|
if (/Linux|X11/i.test(userAgent)) {
|
|
return 'linux'
|
|
}
|
|
return typeof process === 'undefined' ? null : process.platform
|
|
}
|
|
|
|
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
|
|
const localPlatform = platform ?? getCurrentLocalPlatform()
|
|
if (localPlatform === 'darwin') {
|
|
return 'Local Mac'
|
|
}
|
|
if (localPlatform === 'win32') {
|
|
return 'Local Windows'
|
|
}
|
|
if (localPlatform === 'linux') {
|
|
return 'Local Linux'
|
|
}
|
|
return 'This computer'
|
|
}
|
|
|
|
function normalizeHostPart(value: string | null | undefined): string | null {
|
|
const trimmed = value?.trim()
|
|
return trimmed ? trimmed : null
|
|
}
|
|
|
|
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
|
|
return `ssh:${encodeURIComponent(targetId)}`
|
|
}
|
|
|
|
export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` {
|
|
return `runtime:${encodeURIComponent(environmentId)}`
|
|
}
|
|
|
|
// Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing
|
|
// SSH/run-target surfaces. The renderer can't read the target.owner field, so it
|
|
// recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId
|
|
// (main) builds on this same prefix to keep the two in sync.
|
|
export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-'
|
|
|
|
export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
|
return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX)
|
|
}
|
|
|
|
export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null {
|
|
const normalized = normalizeHostPart(value)
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
if (normalized === LOCAL_EXECUTION_HOST_ID) {
|
|
return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID }
|
|
}
|
|
if (normalized.startsWith('ssh:')) {
|
|
const encoded = normalized.slice('ssh:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
// `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and
|
|
// splits at the first one, so an unencoded pipe would rebind an alias to a different host.
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const targetId = decodeURIComponent(encoded)
|
|
return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
if (normalized.startsWith('runtime:')) {
|
|
const encoded = normalized.slice('runtime:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const environmentId = decodeURIComponent(encoded)
|
|
return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null {
|
|
return parseExecutionHostId(value)?.id ?? null
|
|
}
|
|
|
|
export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
const normalized = normalizeHostPart(value)
|
|
if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
|
|
// An omitted scope on a request means this host, not a fan-out. Callers and the
|
|
// renderer share this so both agree on which requests answer with a merge.
|
|
export function requestedExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
return normalizeExecutionHostScope(value ?? LOCAL_EXECUTION_HOST_ID)
|
|
}
|
|
|
|
export function normalizeVisibleExecutionHostIds(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] | null {
|
|
if (!Array.isArray(value)) {
|
|
return null
|
|
}
|
|
const ids: ExecutionHostId[] = []
|
|
const seen = new Set<ExecutionHostId>()
|
|
for (const raw of value) {
|
|
const id = normalizeExecutionHostId(raw)
|
|
if (!id || seen.has(id)) {
|
|
continue
|
|
}
|
|
seen.add(id)
|
|
ids.push(id)
|
|
}
|
|
return ids.length > 0 ? ids : null
|
|
}
|
|
|
|
export function normalizeExecutionHostOrder(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] {
|
|
const normalized = normalizeVisibleExecutionHostIds(value)
|
|
return normalized ?? []
|
|
}
|
|
|
|
// Why the loose member types: a reply reader hands these through as the strings they are on the
|
|
// wire, and this function is already the thing that decides what an unparseable spelling means.
|
|
export function getRepoExecutionHostId(repo: {
|
|
connectionId?: string | null
|
|
executionHostId?: string | null
|
|
}): ExecutionHostId {
|
|
const executionHostId = normalizeExecutionHostId(repo.executionHostId)
|
|
if (executionHostId) {
|
|
return executionHostId
|
|
}
|
|
const connectionId = normalizeHostPart(repo.connectionId)
|
|
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
export function getSshTargetIdForExecutionHost(
|
|
executionHostId: string | null | undefined
|
|
): string | null {
|
|
const parsed = parseExecutionHostId(executionHostId)
|
|
return parsed?.kind === 'ssh' ? parsed.targetId : null
|
|
}
|
|
|
|
// Why: SSH ownership has two spellings on a repo row — the legacy `connectionId`
|
|
// field and the unified `executionHostId`. Routing that reads the raw field answers
|
|
// "local" for a row that only carries `ssh:<target>`, which runs a remote operation
|
|
// on the client. Resolve the host first, then read the connection off it.
|
|
//
|
|
// The two hosts that are not themselves SSH are not the same case:
|
|
//
|
|
// - `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row
|
|
// contradicting itself — the shape main's `resolveRepoOwnershipEvidence` calls
|
|
// `contradictory`. Answering with it hands out an SSH connection for a row that declares
|
|
// itself local.
|
|
// - `runtime:<env>` is a different machine with its own SSH targets, and a nested one appears
|
|
// only in this field (`repoWithFetchedOwner` spreads it through). It is not dialable on its
|
|
// own, but it is addressable as the pair (environmentId, targetId) — which is how the
|
|
// renderer reads it, recovering the environment from the worktree and looking the target up
|
|
// inside it (`selectRuntimeAwareSshStatus`). Dropping it makes a nested-SSH workspace read
|
|
// as local, which is what decides whether a transcript is read on this client.
|
|
//
|
|
// So this answers "which SSH target holds this row's files", not "which connection may this
|
|
// client dial". `getSshTargetIdForExecutionHost` answers the latter; callers routing a
|
|
// client-local PTY or Git provider want that one instead.
|
|
export function getRepoSshConnectionId(
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
|
): string | null {
|
|
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
|
if (host?.kind === 'ssh') {
|
|
return host.targetId
|
|
}
|
|
return host?.kind === 'runtime' ? normalizeHostPart(repo.connectionId) : null
|
|
}
|
|
|
|
export function getWorktreeExecutionHostId(
|
|
worktree: Pick<Worktree, 'hostId'>,
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
|
|
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
|
|
): ExecutionHostId {
|
|
// Why: runtime and SSH snapshots can identify a more precise owner than
|
|
// the repo fallback; every sidebar host decision must use the same precedence.
|
|
return (
|
|
worktree.hostId ??
|
|
(repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId)
|
|
)
|
|
}
|
|
|
|
export function getSettingsFocusedExecutionHostId(
|
|
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
|
): ExecutionHostId {
|
|
const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId)
|
|
return runtimeEnvironmentId
|
|
? toRuntimeExecutionHostId(runtimeEnvironmentId)
|
|
: LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string {
|
|
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return 'All hosts'
|
|
}
|
|
const parsed = parseExecutionHostId(id)
|
|
if (!parsed) {
|
|
// Not "All hosts": an id that names no host is one *unknown* host, and answering with the
|
|
// everything-scope label shows an unroutable row as though it were on every host.
|
|
// Plain English like every other label in this module (`Local Mac`, `This computer`,
|
|
// `All hosts`) — none of them resolve through the renderer's i18n catalog, so a lone
|
|
// translated string here would read inconsistently.
|
|
return 'Unknown host'
|
|
}
|
|
switch (parsed.kind) {
|
|
case 'local':
|
|
return getLocalExecutionHostLabel()
|
|
case 'ssh':
|
|
return parsed.targetId
|
|
case 'runtime':
|
|
return parsed.environmentId
|
|
}
|
|
}
|