mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* Add host-owned automations flow to mobile * fix: resolve initial mobile automation CI failures * Preserve automation target identity and mobile action state * Preserve selected automation targets through discovery and terminal launch * Cover draft reversion after uncertain automation creation * Preserve automation target and submitted action authority * Preserve selected hosts and editor operations across restored state * Refresh mobile web bundle measurements for merged routes * Fix automation history observation and catalog scaling * Fix automation mobile presentation and merged launch bindings * Refresh measured mobile bundle sweep and correct automation test inputs * Move real-module test loads to collection and consolidate imports * Use typed row access to count worktree ownership reads * Refresh selected automation history on matching host events * Fix automation contract types and merged test imports * Fix automation theme updates and committed history ownership * Update folder terminal contract fixtures for canonical host catalogs * Consolidate historical session activation fixture imports * Repair automation contract project boundaries and owner type ports * Preserve acquired automation folder owners and fix integration lint * Retain terminal pane owners captured at connect * Use checked pane manager context in owner retention test * fix: adapt merged editor and terminal test contracts * fix: preserve captured automation targets and terminal owners * fix: honor explicit automation source clears and refusal contracts * test: construct terminal transport mocks with checked types * test: supply retained-owner context in terminal fixtures * test: align terminal fixtures with callable mocks and store ports * fix(mobile): localize automation screens and run details * fix(mobile): use automation translations in context * fix: preserve editor owner checks and automation repair guidance * fix: reconcile captured workspace contracts after main sync * Repair automation integration tests and retained target diagnostics * Keep test support owned by registered unit roots * Expose full blocked automation run reasons in menus * Anchor mobile bundle envelope to measured route sweep * fix(mobile): localize coded automation run failures * fix: preserve automation decisions and captured resource ownership * fix: qualify remaining automation workspace owner consumers * fix: preserve resolved watch and child editor owners * fix(editor): share qualified worktree watch ownership * fix(editor): reuse immutable repo ownership lookup * fix: preserve host ownership without duplicate catalog scans * fix: align automation destination host types and ratchets * fix: align mobile bundle envelope with measured sweep * fix(mobile): resolve automation setup policy before save * test(mobile): qualify reconciled automation target inspection * fix(mobile): keep server setup consent within its authority * fix: accept narrow qualified workspace owner snapshots * fix: reuse qualified owner state for workspace directories * fix(mobile): localize known automation orphan reasons * Correct automation controller and Chat harness types * Refresh editor requests after paired host restart * Refresh measured mobile web bundle envelope
315 lines
12 KiB
TypeScript
315 lines
12 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
|
||
|
||
/** Whether work at this location runs natively on this machine, in a folder this process can read. */
|
||
export function isNativeLocalLocation(location: {
|
||
executionHostId: string
|
||
wslDistro: string | null
|
||
}): boolean {
|
||
return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
/** Trims a stored id; blank is absent, since no write path normalizes it. */
|
||
export function normalizeHostPart(value: string | null | undefined): string | null {
|
||
return value?.trim() || null
|
||
}
|
||
|
||
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
|
||
return `ssh:${encodeURIComponent(targetId)}`
|
||
}
|
||
|
||
/** Legacy `connectionId` spelling: absent means this machine, present means that SSH target. */
|
||
export function getConnectionExecutionHostId(
|
||
connectionId: string | null | undefined
|
||
): ExecutionHostId {
|
||
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||
}
|
||
|
||
export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` {
|
||
return `runtime:${encodeURIComponent(environmentId)}`
|
||
}
|
||
|
||
// Renderer lacks target.owner, so deterministic prefixes identify hidden plumbing; main shares this prefix.
|
||
// Only recipe VMs use runtime-ssh-: each provision replaces their address/filesystem. Main hides targets by owner.
|
||
export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-'
|
||
/** Machine-provider targets: owned like recipe VMs, but persistent, so not `runtime-ssh-`. */
|
||
export const MACHINE_SSH_TARGET_ID_PREFIX = 'machine-'
|
||
|
||
/** A recipe VM's target, whose address and filesystem are replaced every provision. */
|
||
export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
||
return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX)
|
||
}
|
||
|
||
/** Renderer identifies owned plumbing by id because owner is unavailable; direct dialing would dispose its owner’s session. */
|
||
export function isOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
||
return (
|
||
isRuntimeOwnedSshTargetId(targetId) ||
|
||
(typeof targetId === 'string' && targetId.startsWith(MACHINE_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
|
||
}
|
||
// An unencoded | would rebind composeWorktreeHostIdentity’s first-delimiter alias to another 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
|
||
}
|
||
|
||
/** True only for an explicit local host id; absent or unparsable ids return false. */
|
||
export function isLocalExecutionHost(value: string | null | undefined): boolean {
|
||
return parseExecutionHostId(value)?.kind === 'local'
|
||
}
|
||
|
||
export function isRemoteExecutionHostId(value: string | null | undefined): boolean {
|
||
return (parseExecutionHostId(value)?.kind ?? 'local') !== 'local'
|
||
}
|
||
|
||
/** Conflict sentinel uses runtime spelling to prevent local treatment; no environment owns it and it must never be dialed. */
|
||
export const UNRESOLVED_OWNER_HOST_ID = 'runtime:unresolved-owner' satisfies ExecutionHostId
|
||
|
||
export function isUnresolvedOwnerHostId(value: string | null | undefined): boolean {
|
||
return parseExecutionHostId(value)?.id === UNRESOLVED_OWNER_HOST_ID
|
||
}
|
||
|
||
/** {@link parseExecutionHostId} for transports: the unresolved-owner sentinel is not a host. */
|
||
export function parseRoutableExecutionHostId(
|
||
value: string | null | undefined
|
||
): ParsedExecutionHost | null {
|
||
const parsed = parseExecutionHostId(value)
|
||
return parsed?.id === UNRESOLVED_OWNER_HOST_ID ? null : parsed
|
||
}
|
||
|
||
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 request scope means this host; share the rule so renderer and callers agree on fan-out.
|
||
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 ?? []
|
||
}
|
||
|
||
// Wire readers supply strings; this function owns invalid-id interpretation.
|
||
export function getRepoExecutionHostId(repo: {
|
||
connectionId?: string | null
|
||
executionHostId?: string | null
|
||
}): ExecutionHostId {
|
||
const executionHostId = normalizeExecutionHostId(repo.executionHostId)
|
||
if (executionHostId) {
|
||
return executionHostId
|
||
}
|
||
return getConnectionExecutionHostId(normalizeHostPart(repo.connectionId))
|
||
}
|
||
|
||
/** True only for catalog-local files; unknown hosts or a stale connectionId beside local cannot prove locality. */
|
||
export function isRepoOnCatalogHost(repo: {
|
||
connectionId?: string | null
|
||
executionHostId?: string | null
|
||
}): boolean {
|
||
const executionHostId = normalizeHostPart(repo.executionHostId)
|
||
return (
|
||
(executionHostId === null || executionHostId === LOCAL_EXECUTION_HOST_ID) &&
|
||
normalizeHostPart(repo.connectionId) === null
|
||
)
|
||
}
|
||
|
||
/** Legacy automation migration distinguishes an SSH stamp from a legacy dial target. */
|
||
export function isRepoSshOnlyHost(repo: Pick<Repo, 'connectionId' | 'executionHostId'>): boolean {
|
||
return (
|
||
normalizeHostPart(repo.connectionId) === null &&
|
||
parseExecutionHostId(repo.executionHostId)?.kind === 'ssh'
|
||
)
|
||
}
|
||
|
||
export function getSshTargetIdForExecutionHost(
|
||
executionHostId: string | null | undefined
|
||
): string | null {
|
||
const parsed = parseExecutionHostId(executionHostId)
|
||
return parsed?.kind === 'ssh' ? parsed.targetId : null
|
||
}
|
||
|
||
/** The SSH target this client may dial for a row's files; null for local and `runtime:` rows. */
|
||
export function getRepoDialableSshTargetId(
|
||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
||
): string | null {
|
||
return getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo))
|
||
}
|
||
|
||
// Resolve both legacy connectionId and unified ssh:<target> ownership or SSH-only rows would execute locally.
|
||
// A connectionId beside local contradicts ownership and is ignored. Beside runtime:<env>, it names that environment’s nested SSH target.
|
||
// Keep nested targets for transcript/SSH-status reads (selectRuntimeAwareSshStatus); dropping them misclassifies remote files as local.
|
||
// This identifies the filesystem’s SSH target. Client PTY/Git routing must use getRepoDialableSshTargetId, since nested targets require an environment too.
|
||
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
|
||
}
|
||
|
||
/** Resolve both SSH spellings for routing checks; a raw connectionId never reads as local, and ssh:B overrides stale connectionId A. */
|
||
export function getRepoRoutingConnectionId(
|
||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
||
): string | null {
|
||
return getRepoDialableSshTargetId(repo) ?? normalizeHostPart(repo.connectionId)
|
||
}
|
||
|
||
export function getWorktreeExecutionHostId(
|
||
worktree: Pick<Worktree, 'hostId'>,
|
||
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
|
||
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
|
||
): ExecutionHostId {
|
||
// Snapshots can identify a more precise owner than the repo; share precedence across sidebar decisions.
|
||
return worktree.hostId ?? getRepoExecutionHostIdOrDefault(repo, defaultHostId)
|
||
}
|
||
|
||
/** A repo's explicit execution owner; repos without one belong to the focused/default host. */
|
||
export function getRepoExecutionHostIdOrDefault(
|
||
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
|
||
defaultHostId: ExecutionHostId
|
||
): ExecutionHostId {
|
||
return 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) {
|
||
// An invalid id denotes one unknown host, not All hosts. Keep English consistent with other non-i18n host labels.
|
||
return 'Unknown host'
|
||
}
|
||
switch (parsed.kind) {
|
||
case 'local':
|
||
return getLocalExecutionHostLabel()
|
||
case 'ssh':
|
||
return parsed.targetId
|
||
case 'runtime':
|
||
return parsed.environmentId
|
||
}
|
||
}
|