mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
* Add all-host automations with scoped ownership and multi-authority suppo
Enable automations to run on multiple hosts (SSH targets and local) with
owner-fenced mutations, scoped list queries per host, and conflict
resolution. Introduces desktop and runtime authorities as distinct
automation storage owners, with per-host caching, invalidation, and
retry scheduling on the renderer. Captures registration generations for
SSH hosts to survive re-adoption. Adds CLI support for destination
selection and conflict recovery.
* Filter automation create projects by destination host
Only offer projects available on the selected destination, preventing
the mismatches that would fail at submit time. Auto-adjust the project
selection if it becomes unavailable when the destination changes.
* Add runtime storage authority support for automations
- Support both runtime and desktop as automation storage authorities
- Make owner preconditions optional for legacy-client compatibility
- Cache automation list projections to improve performance
- Add per-row repo/worktree resolution for cross-authority collisions
- Extend automation.list RPC to always include owner metadata
* Replace child_process.execFile with runProcess for external automations
- Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy
- Abstract electron app/ipcMain APIs in orca-runtime via environment accessors
- Install fake app environment in automation tests for consistent setup
- Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error)
- Remove external-manager from child-process import allowlists (no longer violates direct import)
* Unify desktop automation CRUD onto the local runtime RPC surface
The desktop authority now speaks the same automation.* RPC contract as
remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call ->
the shared RpcDispatcher. The automations:list/listRuns/create/update/
delete/runNow IPC arms, their preload members, and every renderer
desktop-vs-runtime transport fork are retired; the runtime methods are
the single implementation of scoped lists, owner fencing, and change
publication for both transports (mobile clients already exercised them).
The desktop probe scheduler's priority lease survives the move as an
AutomationService hook the IPC registration installs and the runtime
methods take, so Orca's own automation traffic still parks queued
external-manager probes.
External-manager scope arms and dispatch-loop plumbing stay on IPC by
design; automation change events keep their existing channels (renderer
ingestion already converges them by authority).
* Remove automation ghost SSH tombstone scanning
This functionality for synthesizing tombstones for automation-referenced SSH
targets is no longer needed as part of the automation system refactoring.
* Refuse orphan automations at dispatch time, not migration time
Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`.
* Show all automations in flat table with unified filter menu
- Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters
- Flatten automation list layout to single table instead of host-grouped sections
- Add Host column to display execution host for each automation
- Display active filters as removable pills below toolbar
- Delete unused AutomationHostPicker* components
* Add automation owner fencing and destination validation
- New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility
- Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit
- SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers
* Route automation recovery actions to the origin host
When an automation action fails due to owner fencing, recovery verbs
("Update server", "Reconnect") must run on the host where the refusal
originated: the row's captured owner for row operations, or the
destination the create dialog captured, not the list's filtered host.
* Remove external manager scope limitation notices
Consolidate create destination eligibility checks with a unified predicate
and fix the bug where desktop repo IDs could be sent to runtime hosts where
they cannot resolve.
* Persist only store-derived automation contexts, not client-perspective o
Store contexts must never be based on client-provided runContext or sourceContext
values—clients speak a different perspective (e.g., 'runtime:<id>' for host IDs
they assign), and persisting those makes the store projection orphan automations
it actually owns. Derived contexts now take precedence in create and update paths,
with explicit null still honored to clear a value. Tests verify this by simulating
drift after storage and confirming that moves re-derive while toggles preserve.
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
import type { Store } from '../persistence'
|
|
import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types'
|
|
import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host'
|
|
import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias'
|
|
import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser'
|
|
import {
|
|
buildRemovedSshTargetTombstone,
|
|
readoptOrphanedWorkspacesForTarget
|
|
} from './ssh-target-readoption'
|
|
|
|
export class SshConnectionStore {
|
|
constructor(private store: Store) {}
|
|
|
|
listTargets(): SshTarget[] {
|
|
return this.store.getSshTargets().filter((target) => !isRuntimeOwnedSshTarget(target))
|
|
}
|
|
|
|
/** Map of removed-target id → its last known label, from the re-adoption
|
|
* tombstones. Lets the renderer show a friendly host name for a workspace
|
|
* still pinned to a target that no longer exists. */
|
|
listRemovedTargetLabels(): Record<string, string> {
|
|
const labels: Record<string, string> = {}
|
|
for (const tombstone of this.store.getRemovedSshTargetTombstones()) {
|
|
labels[tombstone.oldTargetId] = tombstone.label
|
|
}
|
|
return labels
|
|
}
|
|
|
|
listSuppressedSshConfigAliases(): string[] {
|
|
return this.store.getDeletedSshConfigAliases()
|
|
}
|
|
|
|
getTarget(id: string): SshTarget | undefined {
|
|
return this.store.getSshTarget(id)
|
|
}
|
|
|
|
addTarget(target: Omit<SshTarget, 'id'>): SshTarget {
|
|
const full: SshTarget = {
|
|
...target,
|
|
configHost: target.configHost ?? target.host,
|
|
// Why: default to 'manual' so user-created targets are never overwritten
|
|
// by a later ~/.ssh/config import (only 'ssh-config' targets are synced).
|
|
source: target.source ?? 'manual',
|
|
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
// Why: a fresh registration; automations fenced on an earlier one must not adopt it.
|
|
generation: this.store.allocateSshTargetGeneration()
|
|
}
|
|
// Why: re-adding a host the user previously deleted is an explicit intent to
|
|
// keep it — lift any tombstone so config sync stops suppressing this alias.
|
|
this.reclaimAlias(full.configHost ?? full.label)
|
|
this.store.addSshTarget(full)
|
|
// Why: re-adopt workspaces that were orphaned when the same host was removed
|
|
// (repos/worktrees still point at the old, now-dead target id). Track the
|
|
// exact migrations so IPC can refresh and renderer can prune only proven stale rows.
|
|
this.lastRepoReadoptions = readoptOrphanedWorkspacesForTarget(this.store, full)
|
|
return full
|
|
}
|
|
|
|
/** Exact migrations from the most recent add/import operation. */
|
|
lastRepoReadoptions: SshRepoReadoption[] = []
|
|
|
|
upsertRuntimeOwnedTarget(
|
|
runtimeId: string,
|
|
target: Omit<SshTarget, 'id' | 'owner' | 'source' | 'lastRequiredPassphrase'>
|
|
): SshTarget {
|
|
const id = getRuntimeOwnedSshTargetId(runtimeId)
|
|
const existing = this.store.getSshTarget(id)
|
|
const next: SshTarget = {
|
|
...target,
|
|
id,
|
|
configHost: target.configHost ?? target.host,
|
|
owner: { type: 'on-demand-runtime', runtimeId },
|
|
source: 'manual',
|
|
// Why: an upsert onto a live registration is not a re-registration — only a fresh id allocates.
|
|
generation: existing?.generation ?? this.store.allocateSshTargetGeneration(),
|
|
...(existing?.lastRequiredPassphrase !== undefined
|
|
? { lastRequiredPassphrase: existing.lastRequiredPassphrase }
|
|
: {})
|
|
}
|
|
if (existing) {
|
|
return this.store.updateSshTarget(id, next) ?? next
|
|
}
|
|
this.store.addSshTarget(next)
|
|
return next
|
|
}
|
|
|
|
updateTarget(id: string, updates: Partial<Omit<SshTarget, 'id'>>): SshTarget | null {
|
|
const updated = this.store.updateSshTarget(id, updates)
|
|
if (updated) {
|
|
// Why: actively editing a target reclaims its alias from the deleted set,
|
|
// so an edit can never leave the host tombstoned.
|
|
this.reclaimAlias(updated.configHost ?? updated.label)
|
|
}
|
|
return updated
|
|
}
|
|
|
|
removeTarget(id: string): void {
|
|
const target = this.store.getSshTarget(id)
|
|
if (target && !isRuntimeOwnedSshTarget(target)) {
|
|
const alias = target.configHost ?? target.label
|
|
if (alias) {
|
|
// Why: tombstone so passive ~/.ssh/config sync does not resurrect the host.
|
|
// The config picker still lists it so re-pick/save can reclaim the alias.
|
|
this.store.addDeletedSshConfigAlias(alias)
|
|
}
|
|
this.store.addRemovedSshTargetTombstone(buildRemovedSshTargetTombstone(target, Date.now()))
|
|
}
|
|
this.store.removeSshTarget(id)
|
|
}
|
|
|
|
private reclaimAlias(alias: string | undefined): void {
|
|
const normalized = normalizeSshConfigAlias(alias)
|
|
if (!normalized) {
|
|
return
|
|
}
|
|
// Why: tombstones persisted before alias folding (and hosts written with different
|
|
// casing) must all be lifted, or a re-add stays suppressed for its case variants.
|
|
for (const stored of this.store.getDeletedSshConfigAliases()) {
|
|
if (normalizeSshConfigAlias(stored) === normalized) {
|
|
this.store.removeDeletedSshConfigAlias(stored)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync targets from ~/.ssh/config: insert new hosts, update existing
|
|
* config-sourced ones in place (so a rotated port takes effect), never touch
|
|
* manual targets. Returns the inserted and updated targets.
|
|
*/
|
|
importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] {
|
|
const readoptions: SshRepoReadoption[] = []
|
|
// Why: the explicit Import action re-adopts every config host, so it clears
|
|
// all tombstones first. The passive on-open sync passes no flag and keeps
|
|
// deleted hosts suppressed.
|
|
if (options?.reAdopt) {
|
|
this.store.clearDeletedSshConfigAliases()
|
|
}
|
|
// Why: aliases are compared case-insensitively everywhere else (picker, duplicate
|
|
// check, tombstones); a case-sensitive Set here would double-insert `Prod` vs `prod`.
|
|
const deletedAliases = new Set(
|
|
this.store.getDeletedSshConfigAliases().map((alias) => normalizeSshConfigAlias(alias))
|
|
)
|
|
const configHosts = loadUserSshConfig()
|
|
const existingTargets = this.store.getSshTargets()
|
|
// Map config-managed targets (and legacy targets that strongly look like
|
|
// prior imports) by their config alias so a repeat import reconciles instead
|
|
// of duplicating. Manual targets are excluded — their alias stays reserved
|
|
// and untouched.
|
|
const syncableByAlias = new Map<string, SshTarget>()
|
|
const manualAliases = new Set<string>()
|
|
for (const existing of existingTargets) {
|
|
const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label)
|
|
if (
|
|
existing.source === 'manual' ||
|
|
(existing.source === undefined && !isLegacyConfigImportTarget(existing))
|
|
) {
|
|
manualAliases.add(alias)
|
|
continue
|
|
}
|
|
if (alias && !syncableByAlias.has(alias)) {
|
|
syncableByAlias.set(alias, existing)
|
|
}
|
|
}
|
|
|
|
// Pass an empty exclusion set so the parser returns a candidate for every
|
|
// config host (within-config de-duplication still applies); reconciliation
|
|
// against existing targets happens here.
|
|
const candidates = sshConfigHostsToTargets(configHosts, new Set())
|
|
const changed: SshTarget[] = []
|
|
// Guard against ever processing the same alias twice in one pass, so a
|
|
// duplicate candidate can never produce a duplicate target — independent of
|
|
// the parser's own within-config de-duplication.
|
|
const processedAliases = new Set<string>()
|
|
|
|
for (const candidate of candidates) {
|
|
const alias = normalizeSshConfigAlias(candidate.configHost ?? candidate.label)
|
|
if (manualAliases.has(alias)) {
|
|
// A manual target owns this alias — never clobber it.
|
|
continue
|
|
}
|
|
if (deletedAliases.has(alias)) {
|
|
// The user deleted this config host — stay deleted until they re-add it
|
|
// or re-adopt config explicitly.
|
|
continue
|
|
}
|
|
if (processedAliases.has(alias)) {
|
|
continue
|
|
}
|
|
processedAliases.add(alias)
|
|
const existing = syncableByAlias.get(alias)
|
|
if (existing) {
|
|
const nextFields = {
|
|
configHost: candidate.configHost,
|
|
host: candidate.host,
|
|
port: candidate.port,
|
|
username: candidate.username,
|
|
identityFile: candidate.identityFile,
|
|
identityAgent: candidate.identityAgent,
|
|
identitiesOnly: candidate.identitiesOnly,
|
|
gssapiAuthentication: candidate.gssapiAuthentication,
|
|
proxyCommand: candidate.proxyCommand,
|
|
jumpHost: candidate.jumpHost
|
|
}
|
|
// Skip the write (and the "synced" report) when nothing changed, so a
|
|
// repeat sync on every pane open is a no-op. A legacy target with no
|
|
// `source` is always rewritten once to stamp it as config-managed.
|
|
const isDirty =
|
|
existing.source !== 'ssh-config' ||
|
|
(Object.keys(nextFields) as (keyof typeof nextFields)[]).some(
|
|
(key) => existing[key] !== nextFields[key]
|
|
)
|
|
if (!isDirty) {
|
|
continue
|
|
}
|
|
const updated = this.store.updateSshTarget(existing.id, {
|
|
...nextFields,
|
|
source: 'ssh-config'
|
|
})
|
|
if (updated) {
|
|
changed.push(updated)
|
|
}
|
|
} else {
|
|
const inserted: SshTarget = {
|
|
...candidate,
|
|
source: 'ssh-config',
|
|
generation: this.store.allocateSshTargetGeneration()
|
|
}
|
|
this.store.addSshTarget(inserted)
|
|
// Why: a freshly-inserted config host may be one the user removed and is
|
|
// now re-importing — re-adopt its orphaned workspaces. Updated-in-place
|
|
// targets keep their id, so their repos were never orphaned.
|
|
readoptions.push(...readoptOrphanedWorkspacesForTarget(this.store, inserted))
|
|
changed.push(inserted)
|
|
}
|
|
}
|
|
|
|
this.lastRepoReadoptions = readoptions
|
|
return changed
|
|
}
|
|
}
|
|
|
|
export function getRuntimeOwnedSshTargetId(runtimeId: string): string {
|
|
return `${RUNTIME_OWNED_SSH_TARGET_ID_PREFIX}${runtimeId}`
|
|
}
|
|
|
|
export function isRuntimeOwnedSshTarget(target: SshTarget): boolean {
|
|
return target.owner?.type === 'on-demand-runtime'
|
|
}
|
|
|
|
function isLegacyConfigImportTarget(target: SshTarget): boolean {
|
|
const alias = target.configHost ?? target.label
|
|
// Why: legacy manual and imported targets both lack `source`. Only adopt the
|
|
// old import shape, where the SSH alias was kept as label/configHost while
|
|
// host stored the resolved HostName; otherwise preserve the user's target.
|
|
return Boolean(
|
|
alias && target.label === alias && target.configHost === alias && target.host !== alias
|
|
)
|
|
}
|