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.
This commit is contained in:
Jinjing
2026-08-23 15:43:11 -07:00
parent ab3b1d07cd
commit 02775968ff
305 changed files with 36013 additions and 2702 deletions
+1
View File
@@ -97,6 +97,7 @@ docs/**
!docs/agent-skill-sharing-implementation-checklist.md
!docs/agent-skill-sharing-installation-plan.md
!docs/ai-vault-process-isolation-plan.md
!docs/automations-all-hosts-design.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
@@ -1,4 +1,18 @@
[
{
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
"kind": "object-property:title",
"text": "Nightly #1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
"kind": "object-property:label",
"text": "Hermes",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
+530
View File
@@ -0,0 +1,530 @@
# All-host Automations
## Status
Proposed. The protocol, identity, and fencing decisions are settled here. The product decisions listed under `Deferred / Open Questions` at the end of this document are still open and need an owner before the first release.
## Summary
The Automations page is global, but its data is currently loaded from one active authority and several code paths later infer ownership from a bare automation, run, repo, workspace, or host ID. That is unsafe once records from multiple desktop and runtime stores appear together.
This design introduces an authority-qualified automation catalog. Every row captures the exact desktop/runtime and self/SSH incarnation that produced it. Lists are cached per visible host, mutations and secondary reads are fenced against that captured incarnation, runtime version skew has an explicit fallback, and failures remain isolated to one authority.
`All hosts` is a renderer aggregation mode, not a backend-wide query. Selecting one host performs no discovery or automation query against unrelated authorities.
## Goals
- Show Orca automations stored by the desktop and every saved remote Orca runtime, including each authority's user-visible SSH targets.
- Make host scope visible and persistent without treating an unhydrated catalog as removal.
- Prevent ID collisions, runtime re-pairing, SSH remove/re-add, and late responses from crossing ownership boundaries.
- Keep useful cached results visible when one authority is slow, offline, incompatible, or stale.
- Route list, edit, delete, pause/resume, Run Now, run history, usage, navigation, and workspace actions through the captured owner.
- Avoid startup fanout, remote-manager fanout, unbounded retries, and all-run history downloads.
- Bring every dispatched run to a terminal state without a client attached, including unselected, page-closed, and headless runs.
- Remain safe when desktop clients and runtime servers update independently.
## Non-goals
- A global server-side automation index.
- Persisting automation list results across app restarts.
- Moving an automation between desktop and runtime authorities.
- Showing external automation managers owned by remote runtimes in the first release.
- Changing mobile routes, mobile RPCs, pairing, E2EE, or relay framing.
- Changing shell, path, Git, or Git-provider behavior.
## Required invariants
1. Storage authority is not inferred from `runContext.hostId`, `schedulerOwner`, the active runtime, or the selected workspace. Those fields may mark a record ambiguous; they may never assign it to a different authority.
2. An ID is unique only inside its authority. Automation, run, repo, workspace, setup, navigation, dialog, availability, and in-flight action keys are authority-qualified.
3. A stable host selection survives label and connection-state changes, but data or actions captured from an old runtime/SSH incarnation do not.
4. Missing catalog data is not evidence of removal. Removal requires a hydrated authoritative catalog or a tombstone.
5. Local selection never contacts a runtime or probes desktop SSH external managers. One runtime selection never contacts another runtime.
6. Target execution health and authority query health are separate. A disconnected SSH target does not prevent its owning, reachable authority from listing or editing stored automations.
7. A parameterless `automation.list` keeps returning the authority's complete list for old clients.
## Ownership and identity
Use separate stable and incarnation-bearing forms:
```ts
type AutomationAuthorityRef =
| { kind: 'desktop' }
| {
kind: 'runtime'
environmentId: string
pairingRevision: number
}
type AutomationHostSelector =
| { kind: 'self' }
| {
kind: 'ssh'
targetId: string
targetGeneration: number
}
type AutomationOwnerRef = {
authority: AutomationAuthorityRef
selector: AutomationHostSelector
}
type StableAutomationHostRef = {
authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string }
selector: { kind: 'self' } | { kind: 'ssh'; targetId: string }
}
type StableAutomationCatalogRef =
| StableAutomationHostRef
| {
authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string }
selector: { kind: 'orphan' }
}
```
`pairingRevision` is the saved runtime environment's existing pairing revision. `targetGeneration` is a durable SSH registration incarnation, not a connection attempt counter. Add an optional generation to stored SSH targets and `SshTargetSummary`; assign one while loading legacy targets and advance it only when a target is deleted/re-created or explicitly re-adopted. Connection, reconnect, and status transitions do not advance it.
Allocate generations from a persisted monotonic counter owned by each automation authority. On load, the authority reloads that counter as a high-water mark — `max(persisted counter, highest generation on any stored target or automation) + 1` — so a counter lost to a rollback can never reissue a generation an automation already captured. The migration first scans stored automations: a referenced missing SSH target creates a bounded removal tombstone with its last known label before generations are assigned. That makes legacy ghosts discoverable from the existing mirrored `removedTargetLabels`, including on a newly paired client with no list cache. Runtime-owned migrations run on the runtime authority, not on the desktop copy of its catalog.
New and migrated automations persist the selected SSH generation as an optional owner field. A legacy automation whose target still exists adopts the target's current generation during migration. A legacy automation whose target is absent becomes an orphan and is not silently assigned to Self or to a later same-ID target.
### Orphan and ambiguous records
An orphan has a known storage authority and no executable owner. An ambiguous record is a desktop-stored automation whose `schedulerOwner` or run context points at a runtime. Both are readable, and both follow the same rules:
- The owning authority refuses to dispatch them. Its scheduler and `automation.runNow` return the typed `target_removed` conflict and record a skipped-run reason. Disabling actions in the client is presentation, not enforcement.
- Migration persists `enabled: false` for every record it classifies as orphaned or ambiguous, so a classified record cannot keep firing on a guessed host before its authority is upgraded.
- Run Now, edit, and workspace navigation are disabled. Pause/resume and Delete stay enabled: both need only the storage authority, which is known.
- Re-adoption is offered only when a compatible target is present under the same authority. When none is, Delete is the only remaining repair. Cross-authority moves stay unsupported.
- Orphan reads are fenced like owned reads. List requests, `automation.show`, and cache entries carry `{ kind: 'orphan' }` as the selector, and commit compares authority, catalog, and request generations only. A record moving into or out of orphan publishes one source and one destination event, or a single unscoped authority event when the old selector cannot be recovered.
Two keys serve different purposes:
- `hostStableKey(StableAutomationCatalogRef)` excludes incarnation fields and is used by the persisted filter and display slot.
- `ownerKey(AutomationOwnerRef)` includes `pairingRevision` and `targetGeneration` and is used by fetched rows, mutations, secondary reads, and request commit checks.
Use one canonical encoder with explicit kind prefixes and encoded components; do not use display labels, bare IDs, or ad hoc `JSON.stringify` order as keys.
```ts
type AutomationListRow =
| {
kind: 'owned'
key: string // ownerKey + automation.id
owner: AutomationOwnerRef
automation: Automation
usageSummary: AutomationUsageSummary | null
}
| {
kind: 'orphan'
key: string // authority stable key + automation.id
authority: AutomationAuthorityRef
automation: Automation
issue: string
}
```
Every owner-qualified navigation record includes the automation ID and, when applicable, the run ID, repo ID, workspace ID, and project-host-setup ID. Existing maps keyed only by repo/workspace ID cannot be used to resolve these rows because cross-host ID collisions are legal.
Before any mutation or secondary read, resolve the current catalog entry for the stable key and compare its full owner to the captured owner. A mismatch fails closed with `This automation's host changed. Reload it before continuing.` The backend repeats this validation for capable runtimes; client-side checking alone is not sufficient.
## Automation host catalog
Create a dedicated automation host catalog. Do not overload the flat `ExecutionHostId`, because Runtime + SSH needs both a parent authority and a nested target.
```ts
type AutomationHostCatalogEntry = {
stableRef: StableAutomationCatalogRef
owner: AutomationOwnerRef | null
stableKey: string
label: string
authorityLabel: string
kind: 'self' | 'ssh' | 'orphan'
catalogState: 'authoritative' | 'unhydrated' | 'removed'
authorityHealth: AutomationAuthorityHealth
executionHealth: AutomationExecutionHealth
querySupport: 'scoped' | 'legacy-unscoped' | 'incompatible'
}
```
Catalog projection rules:
- Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry.
- Project Runtime + Self from saved runtime environments, even while a runtime is offline.
- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only.
- Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages.
- Hide runtime-owned ephemeral SSH targets under the existing visibility rule.
- Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone.
- Do not initiate runtime connections, target-list calls, or SSH connections merely to render the picker. Runtime SSH discovery consumes already mirrored state and becomes authoritative only when `targetsHydrated` is true.
Catalog generation is tracked per authority and advances whenever that authority's authoritative membership or incarnation changes. The commit fence compares only the generation of the authority that owns the entry, so one host's membership change — another runtime's target bucket hydrating, a target added anywhere — cannot discard every other host's in-flight response and exhaust the retry cap. Runtime connection status and SSH connection status update health without changing catalog generation.
Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale-aware label then target ID, the desktop orphan entry, runtime authorities by label then environment ID, and each runtime's SSH targets immediately after its Self entry by label then target ID followed by that authority's orphan entry. Omit empty orphan entries. Construct one collator and precomputed sort fields per catalog rebuild.
## Persisted filter and hydration
Persist this optional UI value:
```ts
type AutomationHostFilter =
| { kind: 'all' }
| { kind: 'host'; host: StableAutomationCatalogRef }
```
Only the stable form is persisted. Restore it after the relevant catalogs settle:
- Desktop + Self is immediately authoritative.
- Desktop SSH absence counts only after `sshTargetsHydrated`.
- Runtime Self absence counts only after the saved runtime catalog settles.
- Runtime SSH absence counts only after that runtime's target bucket hydrates or a removal tombstone provides positive evidence.
While relevant state is unhydrated, retain the selection and show `Loading host…`; do not fall back or write `All hosts` over the saved value. Once positive removal evidence exists, keep an orphan choice if automations/tombstones still reference it; otherwise switch to All hosts and announce the change.
A persisted orphan selection settles only after the owning authority has returned an authoritative `orphanCount` or an old-server unscoped list. If the authority is offline, retain the selection as unavailable rather than assuming the orphan was repaired.
SSH re-adoption migrates automation owner generations, repo/workspace references, removal tombstones, and this persisted filter in one persistence transaction. Same-ID runtime re-pairing retains the display selection, but evicts old authority data and requires a fresh query before rows or actions return.
## List contract and runtime compatibility
Add two string capability constants to `src/shared/protocol-version.ts`:
```ts
export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY =
'automation.list-host-scope.v1' as const
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY =
'automation.owner-fencing.v1' as const
```
The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions.
Both constants must also be appended to the exported `RUNTIME_CAPABILITIES` array in the same file. `getStatus()` advertises only entries drawn from that array, so a declared-but-unregistered capability leaves every capable runtime looking legacy and makes the New/New matrix row unreachable.
The exact `automation.list` request is:
```ts
type AutomationListParams =
| undefined
| null
| {
selector?:
| { kind: 'self' }
| { kind: 'ssh'; targetId: string; expectedTargetGeneration: number }
| { kind: 'orphan' }
}
```
Omitted params, `null`, `{}`, or an omitted `selector` return the authority's complete automation list. This preserves old-client behavior. A supplied selector filters in the authority backend before serialization. Self means records explicitly stored with a self/local execution target; it does not mean “anything not recognized as SSH.”
The response keeps the existing `automations: Automation[]` field so an old client can consume a new server. A capable server adds projection metadata; for a scoped request, `items` is required and has a validated one-to-one match with `automations`:
```ts
type AutomationListResult = {
automations: Automation[]
items?: Array<{
automationId: string
selector:
| { kind: 'self' }
| { kind: 'ssh'; targetId: string; targetGeneration: number }
| { kind: 'orphan'; issue: string }
usageSummary?: AutomationUsageSummary | null
}>
orphanCount?: number
}
```
The client runtime-validates rather than casts. It rejects malformed top-level responses, drops and records malformed individual rows, requires exactly one metadata item per scoped automation ID, verifies every returned selector matches the request, and never reclassifies a mismatched row into Self. A malformed or missing metadata item drops its paired automation from the committed rows and increments the invalid-row counter; the one-to-one requirement is evaluated over the surviving pairs, not over the raw `automations` array, so one bad row neither hides a whole host nor produces an unqualified row. Old clients ignore `items` and `orphanCount`; new clients talking to old servers use the legacy partition below. Any scoped response may report `orphanCount`; a positive count adds the authority's orphan catalog entry and queues one orphan-scope request. An orphan row has known storage authority but no executable owner, so it is readable and all actions are disabled.
Routing is by authority:
- Desktop uses local IPC with the same selector semantics and owner preconditions.
- A capable runtime gets one scoped request per requested host.
- An older runtime gets at most one parameterless `automation.list` request per authority per refresh cycle. Partition that result into all requested Self/SSH cache entries; never make the same full-list request once per selector.
Legacy partitioning is explicit:
- Self requires positive evidence: `executionTargetType` is `local` **and** the owning repo resolves with no connection ID. The desktop store writes the literal `local` whenever that repo lookup misses, and for repos hosted on a runtime, so a bare `local` value is not evidence on its own.
- A valid SSH record with a non-empty target ID goes to that target's entry, creating a ghost entry if necessary. Legacy SSH rows carry no generation: key them by target ID alone and keep them view-only until the authority advertises `automation.owner-fencing.v1`.
- Unknown execution types, missing SSH IDs, contradictory target fields, records whose owning repo no longer resolves, records whose `schedulerOwner` or `runContext.hostId` points at a runtime, and other malformed legacy records go to an `Unassigned legacy automations` orphan entry.
- `runContext.hostId` and `schedulerOwner` mark a record ambiguous. They never override storage ownership, never assign a record to a different authority, and never repair a malformed selector.
- The authority's create and update paths stop re-deriving `local` from a missed repo lookup. They preserve the stored selector and fail closed instead, so a deleted project cannot silently convert an SSH automation into a local one.
The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded.
If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations.
### Owner-fenced operation contract
For capable runtimes, add this optional precondition to `automation.show`, `automation.runs`, `automation.update`, `automation.delete`, and `automation.runNow`; add `destination` to create and to updates that may move selectors:
```ts
type AutomationOwnerPrecondition = {
selector:
| { kind: 'self' }
| { kind: 'ssh'; targetId: string; targetGeneration: number }
}
type AutomationOwnedIdParams = {
id: string
expectedOwner?: AutomationOwnerPrecondition
}
type AutomationCreateDestination = {
destination?: AutomationOwnerPrecondition
}
```
Concretely, show/delete/runNow use `{ id, expectedOwner? }`; runs uses `{ automationId?, expectedOwner? }`; update uses `{ id, updates, expectedOwner?, destination? }`; and create appends `destination?` to its existing fields. Pause and resume are `automation.update` payloads and carry the same `expectedOwner` precondition; if they ever land as dedicated RPCs they take it too, so scheduler state cannot be changed for the wrong incarnation while every neighbouring action is fenced. A capable new client always supplies the applicable fields. Parameterless old-client shapes remain valid.
The authority is implicit in the local IPC endpoint or runtime RPC connection; it is never supplied as a caller-chosen environment ID inside the payload. Runtime calls also pass the captured `pairingRevision` to the existing renderer/main runtime-environment revision guard before transport dispatch and again before accepting the response. The runtime backend compares the expected selector with the stored automation's selector and current SSH registration generation in the same synchronous persistence operation that reads or mutates the record. Create validates the destination immediately before insert. Update validates both the stored source and requested destination before replacement. Validation resolves `destination.selector` against the authority's current saved target registry inside that same operation: Self is allowed, SSH requires a present target ID whose current registration generation matches, and orphan or unknown selectors are rejected with a structured error and no write. A destination is never accepted as a free-form value, so an automation cannot be attached to a ghost or future same-ID target and become runnable on the wrong host once a later registration satisfies the fence.
The preconditions are optional on the wire so old clients can call new servers, but optional is not unenforced. A capable server rejects any mutation or execution request that omits `expectedOwner` when the stored automation carries an SSH selector with a generation, returning a typed upgrade-required conflict with no side effects. The precondition stays genuinely optional only for Self records and for legacy SSH rows that still have no generation, so unfenced mutation never becomes permanent server behavior for a caller that skips the field. A new client sends the fields whenever `automation.owner-fencing.v1` is advertised. A mismatch returns a typed conflict and performs no mutation or execution. Validation, not Zod stripping of unknown fields, defines this behavior.
## Cache and request lifecycle
Keep an in-memory entry per catalog stable key:
```ts
type CacheEntry = {
data: AutomationListRow[]
fetchedAt: number | null
attempt: number
requestGeneration: number
catalogGeneration: number
request: Promise<void> | null
error: AutomationHostQueryError | null
}
```
Errors use a typed code rather than copy matching:
```ts
type AutomationHostQueryError = {
code:
| 'authority_unavailable'
| 'timeout'
| 'permission_denied'
| 'incompatible'
| 'invalid_response'
| 'owner_changed'
| 'target_removed'
| 'unknown'
message: string
retryable: boolean
retryAt: number | null
}
```
Policy:
- TTL is 30 seconds. Fresh data is returned without a request; stale data remains visible during revalidation.
- A request captures stable key, full owner, request generation, the owning authority's catalog generation, and the authority connection generation. The authority connection generation is the saved runtime environment's current `pairingRevision` — the same value the existing renderer/main revision guard compares — and a fixed constant for the desktop authority. It is not a third counter.
- Refresh, mutation invalidation, authority re-pair, SSH re-adoption/removal, and entry eviction advance `requestGeneration` before starting replacement work.
- A response commits only when every captured generation still matches and no removal tombstone supersedes it. Otherwise it is discarded.
- Concurrent callers for the same owner share `request`. One authority-level legacy fallback request may fulfill several entries.
- A failed refresh retains successful data and records the error separately. Success clears `attempt` and `error`.
- Automatic transient retry uses full jitter with a 1-second base and 30-second cap, at most three attempts while the page remains visible. Permanent validation, permission, and incompatibility failures do not retry. Manual Retry bypasses cooldown and starts one new attempt.
- Authority calls use a four-request global pool. The selected host and Desktop + Self have priority. Obsolete queued work is cancelled when the filter/catalog changes; already-sent transport work may finish but must pass the commit fence.
- An All-hosts refresh never initiates an authority connection. It queries only authorities with an established connection; a saved-but-disconnected authority renders a compact status row with a Reconnect action and is fetched only after the user reconnects or selects that host directly. Reconnect triggers one prioritized refresh for stale entries owned by that authority. Nested SSH disconnection does not block a list query when its authority is reachable.
- Manual All-host refresh bypasses TTL for reachable authorities only. Unreachable authorities retain stale data and expose Reconnect; the refresh does not enqueue requests already known to fail.
- Focus/visibility refreshes only stale entries and is coalesced. No fixed polling interval is added.
- Removed non-visible entries are evicted after request invalidation. Retired cache entries are LRU-capped at 256; visible catalog entries and active requests are not retained in that retired pool.
Mutation invalidation happens before the request is sent, so an older in-flight list cannot overwrite the mutation result. Create/update/delete/pause/resume/Run Now invalidate only affected entries. An update may move an automation between Self and SSH selectors inside one authority: remove it optimistically from the source, invalidate both source and destination, and reconcile both responses. Cross-authority movement is rejected.
## Editing, creation, and actions
Edit hydration, conflict checks, save, delete, pause/resume, Run Now, and lazy history all receive the selected row's `AutomationOwnerRef`. They must not call the desktop store or active runtime as a fallback.
The editor resolves project, repo, workspace, folder-workspace, and project-host-setup options within the captured authority. Those option identities are authority-qualified before entering maps. Selecting another project may move between Self and SSH selectors owned by the same authority; the save request includes both expected source owner and destination owner. It cannot move between desktop and runtime authorities.
Creation rules:
- A single concrete host filter preselects and constrains the destination. Orphan entries cannot create.
- Under All hosts, default from the active workspace's qualified owner only when it resolves to a catalog entry with a non-null executable owner, and show that owner explicitly before submit. When that owner is missing, unhydrated, orphaned, or not uniquely resolvable, require an explicit host choice before submit; never fall back to the active runtime or to a bare workspace or repo ID.
- The create request captures and validates the destination incarnation immediately before submit.
- If a concurrent catalog change makes the destination stale, fail closed and preserve the form.
- After success, select the created row. If it is unexpectedly outside the current filter, intentionally switch the filter to its destination and announce that change; never let a successful creation silently disappear.
Dialogs keep their captured owner for their entire lifetime. An authority/target incarnation change disables submit and presents Reload; it never silently retargets an open form.
## Run history, usage, and completion
Run history stays lazy and authority-qualified: fetch only the selected automation's runs, page/cap them under the existing retention rules, and key selection/navigation by owner + automation ID + run ID.
The list must not fetch all runs to build usage. Capable list projections include the existing aggregate fields (`knownRuns`, `unavailableRuns`, token totals, and estimated cost) computed by the authority while it already owns bounded retained runs. Legacy rows show neutral unavailable usage until their selected history is present.
Move dispatched-run completion and usage reconciliation out of `AutomationsPage` rendering into the authority-owned automation service/dispatcher. The desktop service and each runtime service observe their own dispatched sessions, persist terminal status exactly once, and publish change events whether or not any client has the Automations page open. On authority startup, reconcile retained non-terminal runs against owned execution/session state. This prevents unselected or headless runs remaining stuck in `dispatched` and avoids client fanout.
## External automation managers
The first release includes external managers only for Desktop + Self and desktop-owned SSH hosts. Runtime + Self and Runtime + SSH show Orca automations only. This is an explicit scope boundary, not an unknown-source fallback. On a runtime-owned host, both the empty and the populated state say so: the absence of manager rows there is a stated scope boundary and is never presented as "none configured".
Replace the broad desktop `listExternalAutomationManagers()` page call with scoped IPC operations that accept the captured desktop `AutomationOwnerRef` and provider. Every scoped call — list, runs, create, update, and action — re-applies the two checks the broad call enforced, before contacting a relay or launching a provider command: the runtime-owned-target exclusion that keeps hidden targets out of user-facing surfaces, and the same saved-owner comparison used for Orca mutations, including `targetGeneration` against the current SSH registration. A mismatch fails closed with the host-changed conflict and performs no probe. Selecting Local probes only local managers. Selecting one desktop SSH host probes only that target. All hosts schedules `{host, provider}` calls through the same bounded pool at lower priority than Orca automation list and mutation traffic for the selected host and Desktop + Self, cancels them when the filter leaves desktop scope, and does not eagerly probe every SSH target from a Local view. Manager work counts toward the release profiling in-flight assertion.
Manager, job, run, dialog, and action keys include owner + provider + provider ID. Cache and error state are per `{owner, provider}`, separate from Orca automation storage health. An external-manager failure cannot mark the host's Orca store unavailable.
Adding runtime-owned external managers later requires dedicated runtime list/runs/create/update/action RPCs, authority-qualified target types, capabilities, validation, and old-server UX. It must not be implemented by tunneling a desktop-only target or treating unknown authority as Local.
## Security and trust boundaries
Runtime authentication and authorization remain those of the existing paired RPC connection. The server derives authority from that connection, validates selectors against its saved target registry, and treats target IDs only as identifiers; no selector is interpolated into a path or shell command. Owner conflicts, permission errors, and invalid schemas return structured errors without record or prompt contents. Response validation and telemetry never log prompts, precheck commands, run output, credentials, or external-manager payloads.
The renderer cannot bypass fencing by supplying a different authority or generation. Local IPC performs the same saved-owner comparison, and runtime RPC handlers compare against server-owned state. External-manager scoped IPC verifies the requested desktop target and provider allowlist before contacting a relay or launching a provider command.
## Change events and invalidation
Add an `automationsChanged` event to the existing local event channel and runtime client event stream:
```ts
type AutomationsChangedEvent = {
type: 'automationsChanged'
selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' }
reason?: 'definition' | 'run' | 'usage'
}
```
CRUD, scheduler transitions, run creation/status changes, and usage updates publish after persistence succeeds. Runtime authority is derived from the subscription environment and its current pairing revision; it is never accepted from the event body.
A scoped event invalidates its one stable entry. An older or unscoped event invalidates all entries for that one authority only. Coalesce event bursts in one microtask and share any legacy authority request. Reconnect, focus, and TTL revalidation remain fallback paths for older servers that publish no event.
An update that moves selectors publishes one event for the source and one for the destination; if the old selector cannot be recovered, publish one unscoped authority event. Subscribers treat duplicate events as harmless invalidations.
The event is additive. Old clients must continue to ignore the unknown event type. Do not introduce a new stream opcode; if transport framing ever requires one, capability-negotiate it because old decoders may silently drop unknown opcodes.
## User experience
The host picker and search field remain visible in loading, empty, no-match, and failure states. Use the existing Select primitive for eight or fewer entries and the existing searchable Command/Popover for nine or more. Both expose the label `Filter by host`; the searchable variant focuses search on open, Enter selects, and Esc closes without changing selection.
Every row shows a host badge rendered with the existing `RepoBadgeLabel` component already used for host and repo labels elsewhere in the automations UI. The pill `Badge` component has no `muted` variant, and this design does not add one. Badge truncation retains the full accessible name and tooltip. Search runs after host selection and matches name, project, workspace, agent, host label, and at most the first 2,048 prompt characters. Build the normalized search index once per changed row set, not during each render or comparator call.
Keep these states distinct:
- Authority query: loading, fresh, refreshing, stale-error, unavailable, incompatible.
- Execution target: connected, connecting, disconnected, unavailable, unknown.
Under All hosts, rows are grouped by host in the same deterministic catalog order the picker uses, with each host's compact host-level status row anchored at the top of its group, so incomplete authorities do not reflow the list as responses arrive. Healthy and stale rows render inside their group as they arrive. A stale row remains readable. Each persistent failure supplies the relevant Retry, Reconnect, or Update server action; do not rely on a toast for recoverable errors. Run Now is disabled when execution health is insufficient, with a concrete reason, while storage-only actions follow authority and fencing availability.
Use a polite, deduplicated `aria-live` summary for partial failures, worded `<N> of <M> hosts could not be loaded`. Re-announce only when the failed-host count changes, not on every retry attempt. Do not move focus when status rows appear. If filtering or refresh removes the focused row, move focus to the next row, then previous row, then the picker; changing selection must not open a detail or fetch history until the replacement row is rendered.
Empty copy distinguishes `No automations on <host>`, `No automations across loaded hosts`, `No automations match your search`, and `Automations could not be loaded from <host>`. Copy never claims a disconnected or unhydrated host is empty. On a runtime-owned host it also states that external automation managers are not listed for that host in this release, so a scope-limited host is never presented as clean.
## Performance and resource budgets
- No automation or external-manager network work is added to app startup or picker render.
- Selected-host first usable rows require at most one authority request; old-server All hosts requires at most one unscoped list request per authority.
- Concurrency is four remote requests, retries are capped, event bursts are coalesced, and timers/listeners are disposed when the page/controller closes.
- Prompt indexing is bounded per row. Retired cache history is capped. Tombstone history is capped per authority, but a tombstone still referenced by a stored automation, cached row, or persisted filter is retained past the cap: a tombstone becomes evictable only after its owning authority has returned an authoritative catalog with no remaining reference to that target. Positive removal evidence is never discarded while something still depends on it. Run lists continue to use existing retention/pagination caps.
- Cache instrumentation records request counts by authority/stable key, in-flight dedupe hits, discarded stale responses, result row counts/bytes, and refresh duration. It does not log prompts or run output.
- Release profiling covers 1,000 automations across 50 hosts, one offline authority, one old runtime, rapid filter changes, and an event burst. Acceptance: no more than four remote calls in flight, one legacy call per authority per cycle, no stale commit, and no synchronous long task over 50 ms attributable to filter/search on release hardware.
## Migration and mixed-version behavior
Persisted additions are optional so rollback builds ignore them:
- SSH target registration generation.
- Automation SSH owner generation.
- Persisted stable automation host filter.
Migration is idempotent and preserves unknown fields. It assigns generations only with positive current-target evidence, preserves missing references as ghosts, and never rewrites an orphan to Self.
Supported rollback fixtures must prove the previous desktop/runtime builds preserve the optional generation fields on read/write. If a rollback-era state has lost those fields and a same-ID SSH replacement cannot be distinguished from the prior target, re-upgrade classifies the automation as ambiguous and requires explicit re-adoption; it does not guess and run on the replacement host.
Existing desktop-stored records whose `schedulerOwner` or run context points at a runtime need explicit classification. Physical storage remains the authority unless a separate verified migration proves the runtime holds the canonical record. The first release does not delete or automatically transfer such records. Ambiguous records appear in the legacy/orphan entry with actions disabled and recovery guidance, preventing duplicate scheduling or destructive guessing.
Desktop/runtime version matrix:
| Desktop | Runtime | Behavior |
| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. |
| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. |
| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. |
| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. |
No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces.
## Implementation sequence
1. Add canonical stable/owner refs, SSH registration generation persistence/migration, qualified row/run/navigation keys, and owner comparison helpers.
2. Build the dedicated catalog and persisted-filter hydration/re-adoption behavior.
3. Add local scoped IPC, runtime list capability/schema/validation, legacy authority partitioning, and owner-fenced mutation/secondary-read contracts.
4. Add the generation-fenced cache, bounded scheduler, invalidation events, retry policy, and instrumentation.
5. Move run completion/usage reconciliation into authority-owned services and add list usage projections. This step fixes a defect every user hits today — runs stuck in `dispatched` whenever the page is closed — and depends on nothing in steps 1-4, so it can land on its own schedule rather than waiting on the multi-host work.
6. Convert edit/create/actions/dialogs/project/workspace resolution to captured owners.
7. Add picker, badges, search index, partial states, recovery actions, focus, and announcements using the style guide and existing primitives/tokens.
8. Replace broad external-manager discovery with desktop-only scoped provider calls.
9. Remove the active-runtime inference and broad all-run fetch paths only after the new tests pass.
Each step lands with its compatibility tests; do not ship a UI that merges authorities before mutation and navigation identity are converted.
## Test plan
### Identity, catalog, and persistence
- Equal automation/run/repo/workspace IDs under two authorities remain distinct through selection, edit, navigation, and React keys.
- Equal SSH target IDs under Desktop and a runtime do not collide.
- Same-ID runtime re-pair and SSH remove/re-add discard stale rows, requests, dialogs, and actions.
- Unhydrated absence retains the persisted selection; authoritative removal falls back or preserves a ghost as specified.
- Rename preserves stable selection and cache slot; re-adoption migrates automations and filter atomically.
- Ephemeral runtime SSH targets stay hidden; parent outage preserves nested labels without claiming removal.
### Query, cache, and performance
- Local selection makes zero runtime and SSH-manager calls.
- Runtime selection contacts only that runtime; nested SSH disconnection does not block authority listing.
- Capable All hosts makes scoped calls with at most four remote requests in flight.
- Old-server All hosts makes exactly one unscoped request per authority and partitions Self, SSH, and orphan records correctly.
- Fresh/stale/error behavior, in-flight dedupe, capped retry, reconnect refresh, queue cancellation, and LRU eviction are deterministic with fake time.
- Late responses after refresh, mutation, removal, re-adoption, re-pair, and catalog replacement cannot commit.
- Search indexing is bounded and the 1,000-row/50-host profiling fixture meets the stated budgets.
### Actions, create, and edit
- Show, edit hydration/conflict/save, delete, pause/resume, Run Now, runs, usage, and open-workspace route through captured owners.
- Incarnation changes while a dialog is open fail closed and preserve input.
- In-authority selector movement invalidates/removes source and destination correctly; cross-authority movement is rejected.
- Single-host create is constrained; All hosts defaults visibly from the active qualified workspace; post-create selection never disappears silently.
- Folder workspaces and git worktrees both resolve inside the captured authority.
- An enabled automation whose SSH target was deleted produces no run on any host: its authority refuses the dispatch, records a skipped-run reason, and the migration left the record disabled.
- An automation whose owning repo was deleted, and a desktop-stored record scheduled against a runtime, both land in the orphan entry rather than under Desktop + Self.
- Delete and pause remain available on orphan and ambiguous rows; Run Now, edit, and workspace navigation do not.
### Runs and events
- List rendering fetches no broad run history.
- Selected history is lazy and authority-qualified; legacy usage copy is neutral.
- Unselected, page-closed, remote, restarted, and headless dispatched runs reach a terminal state through authority-owned reconciliation on capable authorities. Against an older runtime the run stays `dispatched` and surfaces Update server instead of hanging with no explanation.
- CRUD/scheduler/run/usage events invalidate one selector; old unscoped events invalidate one authority; bursts coalesce.
- Old clients ignore the new event and still use parameterless list behavior.
### Failures, accessibility, and compatibility
- One timeout, malformed response, permission error, offline authority, or incompatible runtime does not hide other hosts.
- Stale data remains readable and persistent errors expose working Retry/Reconnect/Update actions.
- Picker/search remain present in every empty/error state; full badge labels, keyboard behavior, focus recovery, and deduplicated `aria-live` output pass accessibility tests.
- New/old desktop-runtime matrix tests cover omitted/null/empty/scoped list params and unknown optional fields.
- Persisted migration/rollback fixtures cover missing generations, orphan targets, same-ID replacement, and ambiguous desktop-stored remote-scheduled records.
- No mobile legacy fixture, protocol minimum, or mobile package changes; Windows/Linux/macOS static review finds no new platform-specific behavior.
## Release gates
- All unit, integration, and RPC compatibility tests above pass.
- Real desktop + current runtime, desktop + previous runtime, SSH, runtime-owned SSH, runtime re-pair, SSH re-adoption, folder workspace, and offline/reconnect smoke tests pass.
- The request-count and stale-response assertions pass under rapid interaction and network failure.
- There is no unresolved path that infers authority from a bare ID, active runtime, `runContext.hostId`, or `schedulerOwner`.
- External managers remain desktop-scoped unless their full runtime RPC design lands separately.
## Existing code that must change
- `src/main/runtime/rpc/methods/automations.ts`: `automation.list` is currently parameterless.
- `src/renderer/src/components/automations/automation-host-client.ts`: ownership currently collapses to local vs environment and can fall back from `runContext.hostId`.
- `src/renderer/src/components/automations/AutomationsPage.tsx`: refresh currently fetches broad runs, completion reconciliation is page-owned, and edit/refresh paths use the wrong ambient target.
- `src/main/persistence.ts`: update can change execution target and SSH re-adoption does not migrate automations/filter state. Create and update also derive `executionTargetType` from the owning repo, writing the literal `local` whenever that lookup misses.
- `src/main/ipc/automations.ts`: `automations:list` takes no selector, and `automations:markDispatchResult` is driven by the renderer.
- `src/main/automations/service.ts`: only the headless dispatch path reconciles completion; renderer-dispatched runs depend on the page being open.
- `src/renderer/src/store/worktree-repo-index.ts`: bare-ID maps cannot resolve cross-authority collisions.
- `src/renderer/src/store/slices/runtime-environment-ssh.ts`: runtime SSH state is correctly separate and supplies the basis for nested catalog projection, but connection generation is not the durable target registration generation required here.
- `src/main/automations/external-manager.ts`: manager listing currently probes all desktop SSH targets.
- `src/shared/automations-types.ts`: external targets and existing automation owner data lack authority/incarnation qualification.
- `src/shared/runtime-client-events.ts`: no automation invalidation event exists.
- `src/shared/protocol-version.ts`: runtime capabilities are string constants and need the new additive entries.
+82
View File
@@ -0,0 +1,82 @@
/**
* Where a CLI create — or an edit that moves the record's project/workspace —
* wants the automation to land.
*
* The CLI cannot project a host itself, so it asks the same authority that will
* perform the write to resolve the selector it was given and to name its
* current SSH registrations. That captured incarnation rides along on the
* request, and the authority re-checks it inside the write, so a target that
* disappears in between fails the write instead of silently repointing the
* record at a dead or re-registered host.
*/
import {
AUTOMATION_OWNER_CONFLICT_CODES,
AutomationOwnerConflictError
} from '../shared/automation-owner-conflict'
import type { AutomationDestination } from '../shared/automation-owner-precondition'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
import type { SshTargetSummary } from '../shared/ssh-types'
import type { Repo } from '../shared/repo-types'
import type { RuntimeClient } from './runtime-client'
/** The project/workspace selectors the same request carries; both empty means the host cannot change. */
export type AutomationDestinationTarget = { repo?: string; workspace?: string }
async function resolveTargetRepo(
client: RuntimeClient,
target: AutomationDestinationTarget
): Promise<Repo> {
if (target.repo) {
return (await client.call<{ repo: Repo }>('repo.show', { repo: target.repo })).result.repo
}
const worktree = (
await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: target.workspace
})
).result.worktree
return (await client.call<{ repo: Repo }>('repo.show', { repo: `id:${worktree.repoId}` })).result
.repo
}
/**
* `undefined` means the authority registers the target but assigns no
* generation, which is a host with nothing to fence — not a host to fail
* against. A target the authority does not register at all is positive
* evidence that the destination is gone.
*/
async function sshTargetGeneration(
client: RuntimeClient,
targetId: string
): Promise<number | undefined> {
let targets: SshTargetSummary[]
try {
targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')).result
.targets
} catch {
return undefined
}
const match = targets.find((candidate) => candidate.id === targetId)
if (!match) {
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination)
}
return match.generation
}
export async function resolveAutomationDestination(
client: RuntimeClient,
target: AutomationDestinationTarget
): Promise<AutomationDestination | undefined> {
if (!target.repo && !target.workspace) {
return undefined
}
const repo = await resolveTargetRepo(client, target)
const connectionId = repo.connectionId?.trim()
if (!connectionId) {
return { selector: { kind: 'self' } }
}
const generation = await sshTargetGeneration(client, connectionId)
return generation === undefined
? undefined
: { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } }
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope'
import type { Automation } from '../shared/automations-types'
import { formatAutomationList, formatAutomationShow } from './automation-format'
function automation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'Run checks',
precheck: null,
agentId: 'codex',
projectId: 'repo-legacy',
executionTargetType: 'ssh',
executionTargetId: 'box-1',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
}
}
// `orca automations run` sends users to `orca automations show` to find out where
// an automation lives. The stored `executionTarget*` fields survive their host's
// removal untouched, so printing only those renders a dead automation byte-for-byte
// like a healthy one and the named recovery step cannot be completed.
describe('formatAutomationShow reports the host the authority projects', () => {
it('distinguishes a live SSH host from a replaced one by its incarnation', () => {
const live = formatAutomationShow({
automation: automation(),
owner: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 4 } }
})
const replaced = formatAutomationShow({
automation: automation(),
owner: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 9 } }
})
expect(live).toContain('host: ssh:box-1 (generation 4)')
expect(replaced).toContain('host: ssh:box-1 (generation 9)')
expect(live).not.toBe(replaced)
})
it('says outright that an orphan has no host, rather than naming its dead target', () => {
const output = formatAutomationShow({
automation: automation(),
owner: { selector: { kind: 'orphan' } }
})
expect(output).toContain('host: orphan (no host can run this automation)')
// The stored pin stays visible; it is the record's history, not its current owner.
expect(output).toContain('target: ssh:box-1')
})
it('names the local host for a self-owned automation', () => {
const output = formatAutomationShow({
automation: automation({ executionTargetType: 'local', executionTargetId: 'local' }),
owner: { selector: { kind: 'self' } }
})
expect(output).toContain('host: self')
})
// An older authority projects no owner; inventing one from the stored fields would
// report a removed host as healthy.
it('omits the host line when the authority reports no owner', () => {
const output = formatAutomationShow({ automation: automation() })
expect(output).not.toContain('host:')
expect(output).toContain('target: ssh:box-1')
})
})
describe('formatAutomationList reports each row host', () => {
it('names the orphan issue so the list explains why a row cannot run', () => {
const output = formatAutomationList({
automations: [automation(), automation({ id: 'auto-2', name: 'Weekly' })],
items: [
{
automationId: 'auto-1',
selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 4 }
},
{
automationId: 'auto-2',
selector: { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetMissing }
}
]
})
expect(output).toContain('host: ssh:box-1 (generation 4)')
expect(output).toContain(`host: orphan — ${AUTOMATION_ORPHAN_ISSUES.targetMissing}`)
})
it('omits the host line for rows an older authority does not qualify', () => {
const output = formatAutomationList({ automations: [automation()] })
expect(output).not.toContain('host:')
})
})
+146
View File
@@ -0,0 +1,146 @@
/**
* Human-readable automation output for the CLI.
*
* The host lines come from the authority's own projection, never from the
* stored `executionTarget*` fields: a target that was removed — or removed and
* re-registered under the same id — leaves those fields untouched, so printing
* them alone would render a dead host identically to a healthy one and leave
* `orca automations show` unable to complete the recovery it is named in.
*/
import type { Automation, AutomationRun } from '../shared/automations-types'
import type { AutomationListItem } from '../shared/automation-list-scope'
import type { AutomationOwnerPrecondition } from '../shared/automation-owner-precondition'
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck'
import { formatAutomationSchedule } from '../shared/automation-schedules'
export type AutomationListPayload = {
automations: Automation[]
/** Present only when the authority qualifies its rows; an older host sends none. */
items?: AutomationListItem[]
}
export type AutomationShowPayload = {
automation: Automation
/** Present only when the authority can project an owner; an older host sends none. */
owner?: AutomationOwnerPrecondition
}
const ORPHAN_HOST_LABEL = 'orphan (no host can run this automation)'
function formatOwnerSelector(selector: AutomationOwnerPrecondition['selector']): string {
if (selector.kind === 'ssh') {
return `ssh:${selector.targetId} (generation ${selector.targetGeneration})`
}
return selector.kind === 'orphan' ? ORPHAN_HOST_LABEL : 'self'
}
function formatListItemSelector(item: AutomationListItem): string {
const selector = item.selector
if (selector.kind === 'ssh') {
return `ssh:${selector.targetId} (generation ${selector.targetGeneration})`
}
return selector.kind === 'orphan' ? `orphan — ${selector.issue}` : 'self'
}
export function formatAutomationList(result: AutomationListPayload): string {
if (result.automations.length === 0) {
return 'No automations found.'
}
const hosts = new Map(result.items?.map((item) => [item.automationId, item]) ?? [])
return result.automations
.map((automation) => {
const status = automation.enabled ? 'enabled' : 'disabled'
const item = hosts.get(automation.id)
const host = item ? `\nhost: ${formatListItemSelector(item)}` : ''
return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}${host}`
})
.join('\n\n')
}
export function formatAutomationShow(result: AutomationShowPayload): string {
const automation = result.automation
const runContext = automation.runContext ?? null
const projectLines = runContext
? [
`runProjectId: ${runContext.projectId}`,
`runHostId: ${runContext.hostId}`,
`projectHostSetupId: ${runContext.projectHostSetupId}`,
`runRepoId: ${runContext.repoId}`,
`runPath: ${runContext.path}`,
`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`
]
: [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`]
return [
`id: ${automation.id}`,
`name: ${automation.name}`,
`provider: ${automation.agentId}`,
`enabled: ${automation.enabled}`,
`schedule: ${formatAutomationSchedule(automation.rrule)}`,
`rrule: ${automation.rrule}`,
`precheck: ${
automation.precheck
? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout(
automation.precheck.timeoutSeconds
)})`
: 'none'
}`,
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
...projectLines,
`workspaceMode: ${automation.workspaceMode}`,
`workspaceId: ${automation.workspaceId ?? 'null'}`,
`baseBranch: ${automation.baseBranch ?? 'null'}`,
`reuseSession: ${automation.reuseSession}`,
`target: ${automation.executionTargetType}:${automation.executionTargetId}`,
// Omitted rather than guessed: a host that reports no owner has none to report.
...(result.owner ? [`host: ${formatOwnerSelector(result.owner.selector)}`] : []),
`prompt: ${automation.prompt}`
].join('\n')
}
export function formatAutomationRemoved(result: { removed: boolean; id: string }): string {
return result.removed
? `Removed automation ${result.id}.`
: `Automation ${result.id} not removed.`
}
export function formatAutomationRun(result: { run: AutomationRun }): string {
return [
`id: ${result.run.id}`,
`automationId: ${result.run.automationId}`,
`title: ${result.run.title}`,
`status: ${result.run.status}`,
`trigger: ${result.run.trigger}`,
`scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`,
`workspaceId: ${result.run.workspaceId ?? 'null'}`,
`precheck: ${formatAutomationRunPrecheck(result.run)}`,
`error: ${result.run.error ?? 'null'}`
].join('\n')
}
function formatAutomationRunPrecheck(run: AutomationRun): string {
const result = run.precheckResult
if (!result) {
return 'none'
}
const outcome = result.timedOut
? 'timed out'
: result.error
? 'error'
: `exit ${result.exitCode ?? 'unknown'}`
const output = result.stderr.trim() || result.stdout.trim()
return output ? `${outcome}; ${output}` : outcome
}
export function formatAutomationRuns(result: { runs: AutomationRun[] }): string {
if (result.runs.length === 0) {
return 'No automation runs found.'
}
return result.runs
.map(
(run) =>
`${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}`
)
.join('\n\n')
}
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conflict'
import { formatCliError, reportCliError } from './format'
import { RuntimeRpcFailureError } from './runtime-client'
function conflict(code: string, message: string): RuntimeRpcFailureError {
return new RuntimeRpcFailureError({
id: 'request-1',
ok: false,
error: { code, message: `${message}: ${code}` },
_meta: { runtimeId: 'runtime-1' }
})
}
afterEach(() => vi.restoreAllMocks())
describe('automation owner conflicts read as CLI outcomes', () => {
it('drops the machine token from the human message and names the recovery', () => {
const output = formatCliError(
conflict(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged, "This automation's host changed.")
)
expect(output).toContain("This automation's host changed.")
expect(output).not.toContain('automation_owner_changed')
expect(output).toContain('Next step: Run the command again')
})
// A removed SSH host fails identically forever, so proposing a retry would send
// the user in a loop instead of to the two things that actually resolve it.
it('never proposes a retry for a removed target', () => {
const output = formatCliError(
conflict(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved, 'Host removed.')
)
expect(output).not.toMatch(/run the command again|try again/i)
expect(output).toContain('retrying will not change that')
expect(output).toContain('Re-add that SSH host')
expect(output).toContain('orca automations remove')
})
it('keeps the code machine-readable in --json while carrying the same steps', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
reportCliError(
conflict(AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired, 'Owner required.'),
true
)
const payload = JSON.parse(log.mock.calls[0]![0] as string)
expect(payload.error.code).toBe('automation_owner_fencing_required')
expect(payload.error.message).toBe('Owner required.')
expect(payload.error.data.nextSteps[1]).toContain('Update Orca on the host')
})
it('leaves unrelated failures untouched', () => {
const output = formatCliError(conflict('runtime_error', 'Something else'))
expect(output).toBe('Something else: runtime_error')
})
// The trailing token is the convention precisely because a hop can flatten the
// error class. Classifying on `.code` alone reads only the hop that preserved it,
// and the user loses the recovery on every other one.
it('recovers the conflict from the message when a hop flattened the code', () => {
const flattened = new RuntimeRpcFailureError({
id: 'request-1',
ok: false,
error: {
code: 'runtime_error',
message: `This automation's host changed.: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}`
},
_meta: { runtimeId: 'runtime-1' }
})
const output = formatCliError(flattened)
expect(output).not.toContain('automation_owner_changed')
expect(output).toContain('Next step: Run the command again')
})
it('restores the flattened code for --json consumers', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
reportCliError(
new RuntimeRpcFailureError({
id: 'request-1',
ok: false,
error: {
code: 'runtime_error',
message: `Host removed.: ${AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved}`
},
_meta: { runtimeId: 'runtime-1' }
}),
true
)
const payload = JSON.parse(log.mock.calls[0]![0] as string)
expect(payload.error.code).toBe('automation_target_removed')
expect(payload.error.message).toBe('Host removed.')
expect(payload.error.data.nextSteps[0]).toContain('no longer registered')
})
// A local throw never reaches the envelope, so the token is all the CLI has.
it('classifies a locally thrown conflict the same way', () => {
const output = formatCliError(
new Error(
`Destination host is not registered.: ${AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination}`
)
)
expect(output).not.toContain('automation_destination_invalid')
expect(output).toContain('Next step: Run `orca automations show --id <id>`')
})
})
@@ -0,0 +1,40 @@
/**
* What a user should do about each automation owner conflict, phrased for the
* CLI rather than for a window that can re-render itself.
*
* `automation_target_removed` deliberately offers no retry: the SSH host the
* record is pinned to is gone, so every retry fails identically until the user
* re-adds that host or deletes the automation.
*/
import {
AUTOMATION_OWNER_CONFLICT_CODES,
isAutomationOwnerConflictCode
} from '../shared/automation-owner-conflict'
export type AutomationOwnerConflictRecovery = { nextSteps: readonly string[] }
const RECOVERY: Record<string, readonly string[]> = {
[AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: [
'The automation moved to a different host between the read and the write.',
'Run the command again; it captures the current host on each attempt.'
],
[AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: [
'This automation is pinned to an SSH host that is no longer registered, so it cannot run and retrying will not change that.',
'Re-add that SSH host, or delete the automation with `orca automations remove --id <id>`.'
],
[AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: [
'The host accepted the request but did not report which host owns the automation, so the CLI had no owner to send.',
'Update Orca on the host, then run the command again.'
],
[AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: [
'The host this command targets is not registered on the authority that stores the automation.',
'Run `orca automations show --id <id>` to see where it lives, then target a host that authority knows.'
]
}
export function automationOwnerConflictRecovery(
code: string | null | undefined
): AutomationOwnerConflictRecovery | undefined {
return isAutomationOwnerConflictCode(code) ? { nextSteps: RECOVERY[code] } : undefined
}
+44 -4
View File
@@ -1,5 +1,10 @@
import type { CliStatusResult } from '../shared/runtime-types'
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import {
matchAutomationOwnerConflict,
stripAutomationOwnerConflictCode
} from '../shared/automation-owner-conflict'
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
import { prepareComputerCliJsonResult } from './computer-format'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
@@ -47,7 +52,10 @@ export {
formatAutomationRemoved,
formatAutomationRun,
formatAutomationRuns,
formatAutomationShow,
formatAutomationShow
} from './automation-format'
export type { AutomationListPayload, AutomationShowPayload } from './automation-format'
export {
formatEnvironment,
formatEnvironmentList,
formatMemorySnapshot,
@@ -84,6 +92,11 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
// Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
}
if (error instanceof RuntimeClientError) {
const nextSteps = nextStepsFromData(error.data)
if (nextSteps.length > 0) {
@@ -119,14 +132,18 @@ function hasOrchestrationRequestId(data: unknown): boolean {
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(error.response, null, 2))
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
} else {
const response: RuntimeRpcFailure = {
id: 'local',
ok: false,
error: {
code: error instanceof RuntimeClientError ? error.code : 'runtime_error',
message: error instanceof Error ? error.message : String(error),
code:
matchAutomationOwnerConflict(error) ??
(error instanceof RuntimeClientError ? error.code : 'runtime_error'),
message: stripAutomationOwnerConflictCode(
error instanceof Error ? error.message : String(error)
),
data: localCliErrorData(error, context)
},
_meta: {
@@ -140,6 +157,25 @@ export function reportCliError(error: unknown, json: boolean, context: CliErrorC
}
}
/** Machine-readable half of the same recovery the human message carries. */
function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
const code = matchAutomationOwnerConflict(response)
const conflict = automationOwnerConflictRecovery(code)
if (!conflict || !code) {
return response
}
return {
...response,
error: {
...response.error,
// Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
code,
message: stripAutomationOwnerConflictCode(response.error.message),
data: response.error.data ?? conflict
}
}
}
function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
if (nextSteps.length === 0) {
return message
@@ -165,6 +201,10 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
if (error instanceof RuntimeClientError && error.data !== undefined) {
return error.data
}
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return conflict
}
if (
error instanceof RuntimeClientError &&
error.code === 'invalid_argument' &&
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { deriveAutomationExecutionTargetForCreate } from '../../shared/automation-execution-target'
import {
assertAutomationDestination,
assertExecutionTargetMatchesDestination
} from '../../shared/automation-owner-precondition'
import { AUTOMATION_HANDLERS } from './automations'
type SshRegistration = { id: string; label: string; generation?: number }
function ok(result: unknown): unknown {
return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
/**
* Stands in for the storing authority: it answers the reads the CLI makes and
* enforces the destination exactly where persistence does — with the real
* assertions, and only when the client actually supplies one.
*/
function authority(options: {
/** What `ssh.listTargetSummaries` reports while the CLI captures its destination. */
registered: SshRegistration[]
/** Generation the same target carries by the time the write lands. */
generationAtWrite?: number
repoConnectionId?: string | null
}): { call: ReturnType<typeof vi.fn>; writes: { method: string; params: unknown }[] } {
const writes: { method: string; params: unknown }[] = []
const connectionId = options.repoConnectionId ?? null
const call = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'automation.show') {
return ok({
automation: { id: 'a1' },
owner: { selector: { kind: 'ssh', targetId: 'box-0', targetGeneration: 3 } }
})
}
if (method === 'repo.show') {
return ok({ repo: { id: 'r1', connectionId } })
}
if (method === 'ssh.listTargetSummaries') {
return ok({ targets: options.registered })
}
if (method === 'automation.create' || method === 'automation.update') {
writes.push({ method, params: params ?? {} })
const destination = (params as { destination?: never } | undefined)?.destination
if (destination) {
const sshTargetGeneration = (): number | undefined => options.generationAtWrite
assertAutomationDestination(destination, { sshTargetGeneration })
assertExecutionTargetMatchesDestination(
deriveAutomationExecutionTargetForCreate({
repo: { connectionId },
sshTargetGeneration: options.generationAtWrite
}),
destination
)
}
return ok({ automation: { id: 'a1' } })
}
return ok({})
})
return { call, writes }
}
const CREATE_FLAGS: [string, string][] = [
['name', 'nightly'],
['prompt', 'go'],
['provider', 'claude'],
['trigger', 'daily']
]
afterEach(() => vi.restoreAllMocks())
// The expected owner only fences the host the record is leaving. Without the
// destination the arrival is never checked, so a move onto a host that is gone —
// or gone and re-registered as a different machine under the same id — is
// accepted and only surfaces later, at dispatch, as a dead automation.
describe('CLI automation writes fence the host they land on', () => {
it('refuses a selector-moving edit onto an SSH host the authority no longer registers', async () => {
const { call, writes } = authority({ registered: [], repoConnectionId: 'box-1' })
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await expect(
AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
).rejects.toThrow('automation_destination_invalid')
expect(writes).toEqual([])
})
it('refuses a create onto an SSH host the authority no longer registers', async () => {
const { call, writes } = authority({ registered: [], repoConnectionId: 'box-1' })
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await expect(
AUTOMATION_HANDLERS['automations create']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([...CREATE_FLAGS, ['repo', 'repo-on-box-1']]),
json: true
})
).rejects.toThrow('automation_destination_invalid')
expect(writes).toEqual([])
})
it('refuses a selector-moving edit when the host is re-registered between capture and write', async () => {
const { call } = authority({
registered: [{ id: 'box-1', label: 'box 1', generation: 7 }],
generationAtWrite: 8,
repoConnectionId: 'box-1'
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await expect(
AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
).rejects.toThrow('automation_destination_invalid')
})
it('lets a move onto a still-registered host through, carrying the incarnation it captured', async () => {
const { call, writes } = authority({
registered: [{ id: 'box-1', label: 'box 1', generation: 7 }],
generationAtWrite: 7,
repoConnectionId: 'box-1'
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
expect(writes[0]?.params).toMatchObject({
destination: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 7 } }
})
})
// A host that predates SSH generations has no incarnation to capture, so the
// write must still land — failing closed there would break a host that is only old.
it('omits the destination when the authority registers the target without a generation', async () => {
const { call, writes } = authority({
registered: [{ id: 'box-1', label: 'box 1' }],
repoConnectionId: 'box-1'
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
expect(writes[0]?.params).not.toHaveProperty('destination')
})
it('sends a self destination for a local project', async () => {
const { call, writes } = authority({ registered: [], repoConnectionId: null })
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations create']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([...CREATE_FLAGS, ['repo', 'local-repo']]),
json: true
})
expect(writes[0]?.params).toMatchObject({ destination: { selector: { kind: 'self' } } })
})
// Nothing moves, so there is no arrival to fence and no reason to spend the reads.
it('sends no destination when the edit names no project or workspace', async () => {
const { call, writes } = authority({ registered: [] })
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['name', 'renamed']
]),
json: true
})
expect(call).not.toHaveBeenCalledWith('repo.show', expect.anything())
expect(writes[0]?.params).not.toHaveProperty('destination')
})
})
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_HANDLERS } from './automations'
const owner = { selector: { kind: 'ssh', targetId: 'box', targetGeneration: 4 } }
function ok(result: unknown): unknown {
return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function clientAnsweringShow(owner: unknown): { call: ReturnType<typeof vi.fn> } {
const call = vi.fn(async (method: string) =>
method === 'automation.show'
? ok(owner === undefined ? { automation: { id: 'a1' } } : { automation: { id: 'a1' }, owner })
: ok({ automation: { id: 'a1' }, removed: true, id: 'a1', run: { id: 'r1' } })
)
return { call }
}
afterEach(() => vi.restoreAllMocks())
// Why: the fence refuses an unfenced mutation on a generation-bearing SSH record, so
// every CLI mutation has to read the owner first or it cannot touch its own host.
describe('CLI automation mutations carry the owner they read', () => {
it.each([
['automations remove', 'automation.delete'],
['automations run', 'automation.runNow']
])('%s sends the owner from automation.show to %s', async (command, method) => {
const { call } = clientAnsweringShow(owner)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS[command]!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([['id', 'a1']]),
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'automation.show', { id: 'a1' })
expect(call).toHaveBeenNthCalledWith(2, method, { id: 'a1', expectedOwner: owner })
})
it('edit sends the owner alongside the update payload', async () => {
const { call } = clientAnsweringShow(owner)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['name', 'renamed']
]),
json: true
})
expect(call).toHaveBeenNthCalledWith(
2,
'automation.update',
expect.objectContaining({ id: 'a1', expectedOwner: owner })
)
})
// An older host has no fence to satisfy, so a missing owner must not become a
// fabricated one — and must not stop the mutation either.
it('omits the precondition when the host reports no owner', async () => {
const { call } = clientAnsweringShow(undefined)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations run']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([['id', 'a1']]),
json: true
})
expect(call).toHaveBeenNthCalledWith(2, 'automation.runNow', { id: 'a1' })
})
it('leaves reads unfenced, so listing still works on a record whose host is gone', async () => {
const { call } = clientAnsweringShow(owner)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations runs']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([['id', 'a1']]),
json: true
})
expect(call).toHaveBeenCalledTimes(1)
expect(call).toHaveBeenCalledWith('automation.runs', { automationId: 'a1' })
})
})
+70 -24
View File
@@ -13,6 +13,10 @@ import {
type TaskSourceContext,
type WorkspaceRunContext
} from '../../shared/task-source-context'
import type {
AutomationDestination,
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { TuiAgent } from '../../shared/tui-agent'
import {
@@ -28,13 +32,15 @@ import {
formatAutomationRun,
formatAutomationRuns,
formatAutomationShow,
printResult
printResult,
type AutomationShowPayload
} from '../format'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { resolveAutomationDestination } from '../automation-destination'
import { RuntimeClientError } from '../runtime-client'
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
import {
@@ -44,6 +50,7 @@ import {
} from '../worktree-project-target'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
destination?: AutomationDestination
repo?: string
timezone?: string
workspace?: string
@@ -405,6 +412,24 @@ async function getExplicitTarget(
return { repo, workspace }
}
/**
* Reads the record's current owner so the mutation that follows can name it.
*
* The CLI cannot project an owner itself — it has no SSH target registry — so it
* asks the authority that stores the record. A host too old to answer sends
* nothing, and that host has no fence to satisfy either.
*/
async function resolveExpectedOwner(
client: Parameters<CommandHandler>[0]['client'],
id: string
): Promise<AutomationOwnerPrecondition | undefined> {
const result = await client.call<{ automation: Automation; owner?: AutomationOwnerPrecondition }>(
'automation.show',
{ id }
)
return result.result.owner
}
function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext {
const runContext = buildWorkspaceRunContext({
projectId: setup.projectId,
@@ -428,7 +453,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatAutomationList)
},
'automations show': async ({ flags, client, json }) => {
const result = await client.call<{ automation: Automation }>('automation.show', {
const result = await client.call<AutomationShowPayload>('automation.show', {
id: getRequiredStringFlag(flags, 'id')
})
printResult(result, json, formatAutomationShow)
@@ -442,7 +467,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
const sourceContext = getSourceContextFlag(flags)
const workspaceMode =
getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run')
const result = await client.call<{ automation: Automation }>('automation.create', {
// Built before the destination read so a contradictory flag still fails without a runtime call.
const create = {
name: getRequiredStringFlag(flags, 'name'),
prompt: getRequiredStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
@@ -458,43 +484,63 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
enabled: getEnabledFlag(flags),
missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'),
...schedule
} satisfies AutomationCreateParams)
} satisfies AutomationCreateParams
const destination = await resolveAutomationDestination(client, target)
const result = await client.call<{ automation: Automation }>('automation.create', {
...create,
...(destination ? { destination } : {})
})
printResult(result, json, formatAutomationShow)
},
'automations edit': async ({ flags, client, cwd, json }) => {
const target = await getExplicitTarget(flags, cwd, client)
const schedule = getScheduleFlag(flags, false)
const sourceContext = getSourceContextFlag(flags)
const id = getRequiredStringFlag(flags, 'id')
// Built before the owner read so a contradictory flag still fails without a runtime call.
const updates = {
name: getOptionalStringFlag(flags, 'name'),
prompt: getOptionalStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
agentId: getOptionalProviderFlag(flags),
...(target.runContext ? { runContext: target.runContext } : {}),
...(sourceContext !== undefined ? { sourceContext } : {}),
repo: target.repo,
workspace: target.workspace,
workspaceMode: getWorkspaceModeFlag(flags),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
reuseSession: getReuseSessionFlag(flags),
timezone: getOptionalStringFlag(flags, 'timezone'),
enabled: getEnabledFlag(flags),
missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'),
...schedule
} satisfies AutomationUpdateParams
const expectedOwner = await resolveExpectedOwner(client, id)
// Why: expectedOwner only fences the host the record is leaving; an edit that moves it needs the arrival fenced too.
const destination = await resolveAutomationDestination(client, target)
const result = await client.call<{ automation: Automation }>('automation.update', {
id: getRequiredStringFlag(flags, 'id'),
updates: {
name: getOptionalStringFlag(flags, 'name'),
prompt: getOptionalStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
agentId: getOptionalProviderFlag(flags),
...(target.runContext ? { runContext: target.runContext } : {}),
...(sourceContext !== undefined ? { sourceContext } : {}),
repo: target.repo,
workspace: target.workspace,
workspaceMode: getWorkspaceModeFlag(flags),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
reuseSession: getReuseSessionFlag(flags),
timezone: getOptionalStringFlag(flags, 'timezone'),
enabled: getEnabledFlag(flags),
missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'),
...schedule
} satisfies AutomationUpdateParams
id,
...(expectedOwner ? { expectedOwner } : {}),
...(destination ? { destination } : {}),
updates
})
printResult(result, json, formatAutomationShow)
},
'automations remove': async ({ flags, client, json }) => {
const id = getRequiredStringFlag(flags, 'id')
const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { id })
const expectedOwner = await resolveExpectedOwner(client, id)
const result = await client.call<{ removed: boolean; id: string }>('automation.delete', {
id,
...(expectedOwner ? { expectedOwner } : {})
})
printResult(result, json, formatAutomationRemoved)
},
'automations run': async ({ flags, client, json }) => {
const id = getRequiredStringFlag(flags, 'id')
const expectedOwner = await resolveExpectedOwner(client, id)
const result = await client.call<{ run: AutomationRun }>('automation.runNow', {
id: getRequiredStringFlag(flags, 'id')
id,
...(expectedOwner ? { expectedOwner } : {})
})
printResult(result, json, formatAutomationRun)
},
+15 -6
View File
@@ -42,6 +42,8 @@ vi.mock('child_process', async () => {
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
const SELF_OWNER = { selector: { kind: 'self' } }
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
@@ -57,8 +59,11 @@ describe('orca cli worktree awareness', () => {
it('passes positional automation ids to edit, remove, run, and show', async () => {
queueFixtures(
callMock,
okFixture('req_owner_edit', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
okFixture('req_owner_remove', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
okFixture('req_remove', { removed: true, id: 'auto-1' }),
okFixture('req_owner_run', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
okFixture('req_run', {
run: {
id: 'run-1',
@@ -88,8 +93,9 @@ describe('orca cli worktree awareness', () => {
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
id: 'auto-1',
expectedOwner: SELF_OWNER,
updates: {
name: undefined,
prompt: undefined,
@@ -103,13 +109,16 @@ describe('orca cli worktree awareness', () => {
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
id: 'auto-1'
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.delete', {
id: 'auto-1',
expectedOwner: SELF_OWNER
})
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
id: 'auto-1'
expect(callMock).toHaveBeenNthCalledWith(6, 'automation.runNow', {
id: 'auto-1',
expectedOwner: SELF_OWNER
})
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
// A read stays a single call: nothing to fence, so nothing to look up first.
expect(callMock).toHaveBeenNthCalledWith(7, 'automation.show', {
id: 'auto-1'
})
})
+15 -3
View File
@@ -41,7 +41,15 @@ vi.mock('child_process', async () => {
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import {
buildWorktree,
okFixture,
queueFixtures,
workspaceDestinationFixtures,
worktreeListFixture
} from './test-fixtures'
const SELF_OWNER = { selector: { kind: 'self' } }
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
@@ -58,9 +66,11 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
...workspaceDestinationFixtures(),
okFixture('req_create', {
automation: { id: 'auto-1', name: 'Daily review' }
}),
okFixture('req_owner', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
@@ -92,7 +102,7 @@ describe('orca cli worktree awareness', () => {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(
2,
4,
'automation.create',
expect.objectContaining({
workspace: 'id:repo-1::/tmp/repo/feature',
@@ -100,8 +110,10 @@ describe('orca cli worktree awareness', () => {
reuseSession: true
})
)
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', {
expect(callMock).toHaveBeenNthCalledWith(5, 'automation.show', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(6, 'automation.update', {
id: 'auto-1',
expectedOwner: SELF_OWNER,
updates: expect.objectContaining({ reuseSession: false })
})
})
@@ -41,7 +41,9 @@ vi.mock('child_process', async () => {
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { localRepoDestinationFixtures, okFixture, queueFixtures } from './test-fixtures'
const SELF_OWNER = { selector: { kind: 'self' } }
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
@@ -67,6 +69,7 @@ describe('orca cli worktree awareness', () => {
}
queueFixtures(
callMock,
...localRepoDestinationFixtures('repo-gpu'),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
@@ -95,7 +98,7 @@ describe('orca cli worktree awareness', () => {
)
expect(callMock).toHaveBeenNthCalledWith(
1,
2,
'automation.create',
expect.objectContaining({
repo: 'id:repo-gpu',
@@ -107,6 +110,7 @@ describe('orca cli worktree awareness', () => {
it('clears automation source context on edit with null', async () => {
queueFixtures(
callMock,
okFixture('req_owner', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
@@ -116,7 +120,7 @@ describe('orca cli worktree awareness', () => {
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(
1,
2,
'automation.update',
expect.objectContaining({
id: 'auto-1',
+32 -8
View File
@@ -41,9 +41,18 @@ vi.mock('child_process', async () => {
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import {
buildWorktree,
localRepoDestinationFixtures,
okFixture,
queueFixtures,
workspaceDestinationFixtures,
worktreeListFixture
} from './test-fixtures'
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
const SELF_OWNER = { selector: { kind: 'self' } }
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
@@ -58,6 +67,7 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
...workspaceDestinationFixtures(),
okFixture('req_automation_create', {
automation: {
id: 'auto-1',
@@ -105,7 +115,7 @@ describe('orca cli worktree awareness', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
@@ -118,7 +128,8 @@ describe('orca cli worktree awareness', () => {
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
dtstart: expect.any(Number),
destination: { selector: { kind: 'self' } }
})
})
@@ -154,6 +165,7 @@ describe('orca cli worktree awareness', () => {
}
]
}),
...localRepoDestinationFixtures('repo-gpu'),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU review' }
})
@@ -184,9 +196,10 @@ describe('orca cli worktree awareness', () => {
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu')
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(
2,
3,
'automation.create',
expect.objectContaining({
destination: { selector: { kind: 'self' } },
repo: 'id:repo-gpu',
runContext: {
kind: 'workspace-run',
@@ -221,6 +234,9 @@ describe('orca cli worktree awareness', () => {
}
]
}),
// Why: every mutation reads the record's owner first so it can name it back.
okFixture('req_owner', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
...localRepoDestinationFixtures('repo-gpu'),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'GPU review' }
})
@@ -233,11 +249,13 @@ describe('orca cli worktree awareness', () => {
)
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.show', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(
2,
4,
'automation.update',
expect.objectContaining({
id: 'auto-1',
destination: { selector: { kind: 'self' } },
updates: expect.objectContaining({
repo: 'id:repo-gpu',
runContext: {
@@ -364,6 +382,7 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
...workspaceDestinationFixtures(),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'Daily review' }
})
@@ -392,7 +411,7 @@ describe('orca cli worktree awareness', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
@@ -404,7 +423,8 @@ describe('orca cli worktree awareness', () => {
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
dtstart: expect.any(Number),
destination: { selector: { kind: 'self' } }
})
})
@@ -412,6 +432,8 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_owner', { automation: { id: 'auto-1' }, owner: SELF_OWNER }),
...workspaceDestinationFixtures(),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
@@ -426,8 +448,10 @@ describe('orca cli worktree awareness', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
expect(callMock).toHaveBeenNthCalledWith(5, 'automation.update', {
id: 'auto-1',
expectedOwner: SELF_OWNER,
destination: { selector: { kind: 'self' } },
updates: {
name: undefined,
prompt: undefined,
+16
View File
@@ -61,6 +61,22 @@ export function okFixture(id: string, result: unknown): OkFixture {
return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
/** The project read a CLI automation write makes to fence the host it lands on. */
export function localRepoDestinationFixtures(repoId = 'repo-1'): OkFixture[] {
return [okFixture('req_repo_show', { repo: { id: repoId, connectionId: null } })]
}
/** A workspace selector resolves through its workspace before its project. */
export function workspaceDestinationFixtures(
repoId = 'repo-1',
worktreePath = '/tmp/repo/feature'
): OkFixture[] {
return [
okFixture('req_worktree_show', { worktree: { id: `${repoId}::${worktreePath}`, repoId } }),
...localRepoDestinationFixtures(repoId)
]
}
export function queueFixtures(mock: Mock, ...fixtures: OkFixture[]): void {
for (const fixture of fixtures) {
mock.mockResolvedValueOnce(fixture)
-100
View File
@@ -1,7 +1,3 @@
import type { Automation, AutomationRun } from '../shared/automations-types'
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck'
import { formatAutomationSchedule } from '../shared/automation-schedules'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type {
RuntimeRepoList,
@@ -174,99 +170,3 @@ export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }):
)
.join('\n')
}
export function formatAutomationList(result: { automations: Automation[] }): string {
if (result.automations.length === 0) {
return 'No automations found.'
}
return result.automations
.map((automation) => {
const status = automation.enabled ? 'enabled' : 'disabled'
return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}`
})
.join('\n\n')
}
export function formatAutomationShow(result: { automation: Automation }): string {
const automation = result.automation
const runContext = automation.runContext ?? null
const projectLines = runContext
? [
`runProjectId: ${runContext.projectId}`,
`runHostId: ${runContext.hostId}`,
`projectHostSetupId: ${runContext.projectHostSetupId}`,
`runRepoId: ${runContext.repoId}`,
`runPath: ${runContext.path}`,
`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`
]
: [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`]
return [
`id: ${automation.id}`,
`name: ${automation.name}`,
`provider: ${automation.agentId}`,
`enabled: ${automation.enabled}`,
`schedule: ${formatAutomationSchedule(automation.rrule)}`,
`rrule: ${automation.rrule}`,
`precheck: ${
automation.precheck
? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout(
automation.precheck.timeoutSeconds
)})`
: 'none'
}`,
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
...projectLines,
`workspaceMode: ${automation.workspaceMode}`,
`workspaceId: ${automation.workspaceId ?? 'null'}`,
`baseBranch: ${automation.baseBranch ?? 'null'}`,
`reuseSession: ${automation.reuseSession}`,
`target: ${automation.executionTargetType}:${automation.executionTargetId}`,
`prompt: ${automation.prompt}`
].join('\n')
}
export function formatAutomationRemoved(result: { removed: boolean; id: string }): string {
return result.removed
? `Removed automation ${result.id}.`
: `Automation ${result.id} not removed.`
}
export function formatAutomationRun(result: { run: AutomationRun }): string {
return [
`id: ${result.run.id}`,
`automationId: ${result.run.automationId}`,
`title: ${result.run.title}`,
`status: ${result.run.status}`,
`trigger: ${result.run.trigger}`,
`scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`,
`workspaceId: ${result.run.workspaceId ?? 'null'}`,
`precheck: ${formatAutomationRunPrecheck(result.run)}`,
`error: ${result.run.error ?? 'null'}`
].join('\n')
}
function formatAutomationRunPrecheck(run: AutomationRun): string {
const result = run.precheckResult
if (!result) {
return 'none'
}
const outcome = result.timedOut
? 'timed out'
: result.error
? 'error'
: `exit ${result.exitCode ?? 'unknown'}`
const output = result.stderr.trim() || result.stdout.trim()
return output ? `${outcome}; ${output}` : outcome
}
export function formatAutomationRuns(result: { runs: AutomationRun[] }): string {
if (result.runs.length === 0) {
return 'No automation runs found.'
}
return result.runs
.map(
(run) =>
`${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}`
)
.join('\n\n')
}
@@ -0,0 +1,345 @@
/**
* Removing a host — or removing and re-registering it under the same id — leaves
* hostId, repoId and path untouched, so every other run-target check still passes
* while the host is gone or is a different machine. The list already calls both
* records orphans; without this fence the scheduler keeps firing them anyway.
*
* What must keep dispatching is the record whose scheduler lives on a remote
* server: the list files it as an orphan too, and on that server it is exactly
* what the scheduler exists to run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { Automation } from '../../shared/automations-types'
import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope'
import type { Store } from '../persistence'
import { resolveAutomationRunTarget } from './run-target-resolution'
import { AutomationService } from './service'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
async function createStore(): Promise<Store> {
vi.resetModules()
const { Store: StoreClass, initDataPath } = await import('../persistence')
initDataPath()
return new StoreClass()
}
/** The reachable shape: a runtime-owned id is derived, not minted per lifecycle. */
const TARGET_ID = 'runtime-ssh-recipe-1'
const REPLACED =
'The automation host was removed and re-registered, so this automation must be re-adopted before it can run.'
const MISSING =
'The automation host is no longer registered, so this automation has nowhere to run.'
function sshTarget(generation: number | undefined): SshTarget {
return {
id: TARGET_ID,
label: 'devbox',
host: 'devbox.internal',
port: 22,
username: 'orca',
...(generation === undefined ? {} : { generation })
}
}
/** Daily at 09:00 on an SSH host, with whatever generation that host currently has. */
function seedSshAutomation(store: Store, options: { capture?: boolean } = {}): Automation {
store.addSshTarget(sshTarget(options.capture === false ? undefined : 1))
store.addRepo({
id: 'r1',
path: '/repo',
displayName: 'test',
badgeColor: '#fff',
addedAt: 1,
connectionId: TARGET_ID
} as Repo)
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
}
/** A second record refused for a different reason, to prove the two never collide. */
function seedMissingSetupAutomation(store: Store): Automation {
store.addRepo({
id: 'r2',
path: '/other',
displayName: 'other',
badgeColor: '#fff',
addedAt: 1
} as Repo)
return store.createAutomation({
name: 'Other check',
prompt: 'Check the other repo',
agentId: 'claude',
projectId: 'r2',
runContext: {
kind: 'workspace-run',
projectId: 'project-2',
hostId: 'local',
projectHostSetupId: 'missing-setup',
repoId: 'r2',
path: '/other'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
}
/**
* The record a remote server's own scheduler owns. The list files it
* `orphan(scheduledElsewhere)`, so a blanket orphan refusal would stop remote
* scheduling everywhere it is the point of the feature.
*/
function seedRemoteScheduledAutomation(store: Store): Automation {
store.addRepo({
id: 'r3',
path: '/remote',
displayName: 'remote',
badgeColor: '#fff',
addedAt: 1,
executionHostId: 'runtime:vm-1'
} as Repo)
return store.createAutomation({
name: 'Remote check',
prompt: 'Check the remote repo',
agentId: 'claude',
projectId: 'r3',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
}
/** Remove then re-register under the same id — the only way a generation advances. */
function reRegisterTarget(store: Store): void {
store.removeSshTarget(TARGET_ID)
store.addSshTarget(sshTarget(store.allocateSshTargetGeneration()))
}
function attachedService(store: Store): {
service: AutomationService
send: ReturnType<typeof vi.fn>
} {
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({ isDestroyed: () => false, send } as never)
return { service, send }
}
/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */
async function evaluateAt(
store: Store,
service: AutomationService,
automationId: string,
when: string
): Promise<void> {
const nextRunAt = (): number | undefined =>
store.listAutomations().find((entry) => entry.id === automationId)?.nextRunAt
const before = nextRunAt()
vi.setSystemTime(new Date(when))
service.setRendererReady()
await vi.waitFor(() => expect(nextRunAt()).toBeGreaterThan(before ?? 0))
}
describe('scheduled dispatch fenced on the host the record captured', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-dispatch-fence-test-'))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('refuses the run once the same id carries a new registration', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
const { service, send } = attachedService(store)
reRegisterTarget(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).not.toHaveBeenCalled()
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'skipped_unavailable',
error: REPLACED
})
})
it('refuses the run once its host is gone entirely', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
const { service, send } = attachedService(store)
store.removeSshTarget(TARGET_ID)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).not.toHaveBeenCalled()
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'skipped_unavailable',
error: MISSING
})
})
// The trap a blanket orphan refusal would fall into: on a serve-mode host these
// records are the whole feature, and the list calls every one of them an orphan.
it('still dispatches a record whose scheduler lives on a remote server', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedRemoteScheduledAutomation(store)
const send = vi.fn()
const service = new AutomationService(store, {
tickMs: 60_000,
allowRemoteHostScheduling: true
})
service.setWebContents({ isDestroyed: () => false, send } as never)
expect(automation.schedulerOwner).toBe('remote_host_service')
expect(
store.listAutomationsForScope().items.find((entry) => entry.automationId === automation.id)
?.selector
).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere })
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything())
})
// The whole point of the fence: one verdict, so the two can't drift apart.
it('refuses exactly the record the list files as an orphan', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
reRegisterTarget(store)
const item = store
.listAutomationsForScope()
.items.find((entry) => entry.automationId === automation.id)
expect(item?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
})
it('dispatches while the registration the record captured is still the live one', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
const { service, send } = attachedService(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything())
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching')
})
// Absent is not mismatched: fencing these would silently stop every record
// written before generations existed.
it('dispatches a record that captured no generation at all', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store, { capture: false })
// The migration stamps the target long after the record was written.
store.updateSshTarget(TARGET_ID, { generation: store.allocateSshTargetGeneration() })
const { service, send } = attachedService(store)
expect(automation.executionTargetGeneration).toBeUndefined()
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything())
})
it('leaves a legacy record with no run context alone', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
store.updateAutomation(
automation.id,
{ runContext: null },
{ expectedOwner: store.automationOwnerPrecondition(automation.id) ?? undefined }
)
const { service, send } = attachedService(store)
reRegisterTarget(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything())
})
// The fold in #26 keys on byte-identical error text. A generation number, a host
// label or a timestamp in this message would defeat it and restore one row per
// occurrence; colliding with another diagnosis would merge two of them.
it('refuses in bytes that neither vary nor collide with another skip reason', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
const other = seedMissingSetupAutomation(store)
reRegisterTarget(store)
const first = resolveAutomationRunTarget(store, automation)
reRegisterTarget(store)
const second = resolveAutomationRunTarget(store, automation)
expect(first).toEqual({ ok: false, error: REPLACED })
// Two different live generations, one string: nothing per-occurrence leaked in.
expect(second).toEqual(first)
expect(resolveAutomationRunTarget(store, other)).toEqual({
ok: false,
error: 'Project is not set up on the selected automation host anymore.'
})
// A gone host and a replaced one are different diagnoses and must never fold together.
store.removeSshTarget(TARGET_ID)
expect(resolveAutomationRunTarget(store, automation)).toEqual({ ok: false, error: MISSING })
})
// Composes with the coalescing from #26 rather than fighting it: a refusal the
// user cannot fix from the host side must not write one row per occurrence.
it('folds repeated refusals into a single record', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedSshAutomation(store)
const { service } = attachedService(store)
reRegisterTarget(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00')
const runs = store.listAutomationRuns(automation.id)
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({ error: REPLACED, occurrenceCount: 3 })
})
})
@@ -0,0 +1,91 @@
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { RemovedSshTargetTombstone } from '../../shared/ssh-types'
import {
capRemovedSshTargetTombstones,
collectAutomationReferencedSshTargetIds
} from '../ssh/removed-ssh-target-tombstone-retention'
/**
* Positive removal evidence for SSH targets that only a stored automation still
* references. Legacy state has no tombstone for a target removed before repos
* pointed at it, so the ghost host would be invisible — including on a freshly
* paired client with no list cache. Synthesizing a bounded tombstone puts the
* last known label into the mirrored `removedTargetLabels` map instead.
*
* The synthetic record carries no host/port/username, so it is stamped
* `origin: 'automation-scan'` and the re-adoption matcher refuses to match it on
* the identity tuple — a ghost can never steal a real, unrelated new target.
*/
/** SSH target ids referenced by an automation but absent from the target registry. */
export function collectMissingAutomationSshTargetIds(
automations: readonly Automation[],
sshTargets: readonly { id: string }[]
): Set<string> {
const known = new Set(sshTargets.map((target) => target.id))
const missing = new Set<string>()
for (const targetId of collectAutomationReferencedSshTargetIds(automations)) {
if (!known.has(targetId)) {
missing.add(targetId)
}
}
return missing
}
// Label precedence per the design: existing tombstone, else the owning repo, else the raw id.
function ghostLabel(targetId: string, repos: readonly Repo[]): string {
const owningRepo = repos.find((repo) => repo.connectionId === targetId)
return owningRepo?.displayName?.trim() || targetId
}
export type GhostSshTombstoneScanResult = {
removedSshTargetTombstones: RemovedSshTargetTombstone[]
changed: boolean
}
/**
* Add a synthetic tombstone for every automation-referenced SSH target that has
* neither a live registration nor an existing tombstone. Idempotent: a second
* pass finds the tombstones it wrote and changes nothing.
*/
export function scanAutomationsForGhostSshTargets(input: {
automations: readonly Automation[]
sshTargets: readonly { id: string }[]
repos: readonly Repo[]
removedSshTargetTombstones: readonly RemovedSshTargetTombstone[]
now: number
}): GhostSshTombstoneScanResult {
const existing = new Set(input.removedSshTargetTombstones.map((entry) => entry.oldTargetId))
const added: RemovedSshTargetTombstone[] = []
for (const targetId of collectMissingAutomationSshTargetIds(
input.automations,
input.sshTargets
)) {
if (existing.has(targetId)) {
continue
}
added.push({
oldTargetId: targetId,
host: '',
port: 0,
username: '',
label: ghostLabel(targetId, input.repos),
removedAt: input.now,
origin: 'automation-scan'
})
}
if (added.length === 0) {
return {
removedSshTargetTombstones: [...input.removedSshTargetTombstones],
changed: false
}
}
return {
removedSshTargetTombstones: capRemovedSshTargetTombstones(
[...input.removedSshTargetTombstones, ...added],
collectAutomationReferencedSshTargetIds(input.automations)
),
changed: true
}
}
@@ -0,0 +1,176 @@
/**
* The user outranks the load migration. Every case here runs the real load path
* twice — the migration is what re-runs on restart, so a test that only calls it
* once cannot see the flip-back it used to cause.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { isAutomationDisabledByOwnerMigration } from '../../shared/automation-enablement-decision'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'a1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-local',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const REPOS: Repo[] = [
{ id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 },
{
id: 'repo-ssh',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 2,
connectionId: 'ssh-gone'
}
] as Repo[]
const AUTOMATIONS: Automation[] = [
automation({ id: 'local-1' }),
automation({
id: 'orphan-1',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
executionTargetGeneration: 3
}),
// schedulerOwner alone makes this ambiguous: a desktop record scheduled elsewhere.
automation({ id: 'ambiguous-1', schedulerOwner: 'remote_host_service' })
]
type LoadedStore = Awaited<ReturnType<typeof loadStore>>
function seed(): void {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos: REPOS,
sshTargets: [] as SshTarget[],
automations: AUTOMATIONS
}),
'utf-8'
)
}
async function loadStore() {
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
/** The restart: the running store's writes hit disk, then a fresh load re-migrates them. */
async function restart(store: LoadedStore): Promise<LoadedStore> {
store.flush()
return loadStore()
}
function find(store: LoadedStore, id: string): Automation {
const found = store.listAutomations().find((entry) => entry.id === id)
if (!found) {
throw new Error(`Automation ${id} is missing`)
}
return found
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-enablement-'))
seed()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
describe('owner migration enablement decisions', () => {
it('disables an unowned record on first load and records itself as the decider', async () => {
const store = await loadStore()
for (const id of ['orphan-1', 'ambiguous-1']) {
const migrated = find(store, id)
expect(migrated.enabled).toBe(false)
expect(migrated.enabledDecidedBy).toBe('owner_migration')
expect(isAutomationDisabledByOwnerMigration(migrated)).toBe(true)
}
expect(find(store, 'local-1').enabledDecidedBy).toBeUndefined()
})
it.each(['orphan-1', 'ambiguous-1'])(
'keeps a user re-enable of %s across a restart',
async (id) => {
const first = await loadStore()
expect(first.updateAutomation(id, { enabled: true }).enabled).toBe(true)
const reloaded = await restart(first)
const survived = find(reloaded, id)
expect(survived.enabled).toBe(true)
expect(survived.enabledDecidedBy).toBe('user')
expect(isAutomationDisabledByOwnerMigration(survived)).toBe(false)
}
)
it('still leaves a re-enabled orphan alone after several restarts', async () => {
let store = await loadStore()
store.updateAutomation('orphan-1', { enabled: true })
store = await restart(store)
store = await restart(store)
expect(find(store, 'orphan-1').enabled).toBe(true)
})
it('attributes a user disable to the user, not to the migration', async () => {
const store = await loadStore()
store.updateAutomation('orphan-1', { enabled: true })
const disabled = store.updateAutomation('orphan-1', { enabled: false })
expect(disabled.enabledDecidedBy).toBe('user')
expect(isAutomationDisabledByOwnerMigration(disabled)).toBe(false)
// The record is off and stays off: the user's decision is not re-attributed on load.
const reloaded = await restart(store)
expect(find(reloaded, 'orphan-1').enabledDecidedBy).toBe('user')
})
it('does not attribute an unrelated edit to the user', async () => {
const store = await loadStore()
expect(store.updateAutomation('orphan-1', { prompt: 'changed' }).enabledDecidedBy).toBe(
'owner_migration'
)
})
})
@@ -0,0 +1,295 @@
/**
* The desktop authority's end of the scoped-list and owner-fenced contracts:
* a parameterless read still answers with everything it stores, a scoped read is
* qualified per row, and no mutation or dispatch may cross an incarnation
* boundary — including one that a client silently declines to name.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'a1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-local',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const REPOS: Repo[] = [
{ id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 },
{
id: 'repo-ssh',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 2,
connectionId: 'ssh-1'
}
] as Repo[]
const TARGETS: SshTarget[] = [
{ id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 }
] as SshTarget[]
const AUTOMATIONS: Automation[] = [
automation({ id: 'local-1', name: 'A local' }),
automation({
id: 'ssh-1-a',
name: 'B ssh',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 7
}),
automation({
id: 'orphan-1',
name: 'C orphan',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
executionTargetGeneration: 3,
enabled: false
})
]
async function createStore() {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos: REPOS,
sshTargets: TARGETS,
sshTargetGenerationCounter: 7,
automations: AUTOMATIONS
}),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-fencing-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
const OWNED_SSH = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } as const
describe('scoped automation list', () => {
it('keeps the parameterless list at the authority-complete legacy shape', async () => {
const store = await createStore()
expect(store.listAutomations().map((entry) => entry.id)).toEqual([
'local-1',
'ssh-1-a',
'orphan-1'
])
})
it('qualifies every row and reports the authority orphan count', async () => {
const store = await createStore()
const result = store.listAutomationsForScope({ selector: { kind: 'self' } })
expect(result.automations.map((entry) => entry.id)).toEqual(['local-1'])
expect(result.items).toEqual([
{ automationId: 'local-1', selector: { kind: 'self' }, usageSummary: null }
])
expect(result.orphanCount).toBe(1)
})
it('answers an SSH scope only for the requested registration', async () => {
const store = await createStore()
const result = store.listAutomationsForScope({
selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 }
})
expect(result.automations.map((entry) => entry.id)).toEqual(['ssh-1-a'])
expect(() =>
store.listAutomationsForScope({
selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 6 }
})
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged }))
expect(() =>
store.listAutomationsForScope({
selector: { kind: 'ssh', targetId: 'ssh-gone', expectedTargetGeneration: 3 }
})
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }))
})
it('exposes orphans only under the orphan scope', async () => {
const store = await createStore()
const result = store.listAutomationsForScope({ selector: { kind: 'orphan' } })
expect(result.automations.map((entry) => entry.id)).toEqual(['orphan-1'])
expect(result.items[0]?.selector.kind).toBe('orphan')
})
})
describe('owner-fenced mutations', () => {
it('passes when the captured owner still matches', async () => {
const store = await createStore()
const updated = store.updateAutomation(
'ssh-1-a',
{ enabled: false },
{ expectedOwner: OWNED_SSH }
)
expect(updated.enabled).toBe(false)
})
it('refuses an update, delete, and dispatch fenced on a stale generation', async () => {
const store = await createStore()
const stale = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } } as const
const conflict = expect.objectContaining({
code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged
})
expect(() =>
store.updateAutomation('ssh-1-a', { enabled: false }, { expectedOwner: stale })
).toThrowError(conflict)
expect(() => store.deleteAutomation('ssh-1-a', { expectedOwner: stale })).toThrowError(conflict)
expect(() =>
store.assertAutomationOwnerFence({
id: 'ssh-1-a',
expectedOwner: stale,
operation: 'execute'
})
).toThrowError(conflict)
expect(store.listAutomations().find((entry) => entry.id === 'ssh-1-a')?.enabled).toBe(true)
})
it('refuses an unfenced mutation of a record pinned to a generation', async () => {
const store = await createStore()
const conflict = expect.objectContaining({
code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired
})
expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError(conflict)
expect(() => store.deleteAutomation('ssh-1-a')).toThrowError(conflict)
expect(store.listAutomations()).toHaveLength(3)
})
it('leaves a legacy client unaffected on a self record', async () => {
const store = await createStore()
expect(store.updateAutomation('local-1', { enabled: false }).enabled).toBe(false)
store.deleteAutomation('local-1')
expect(store.listAutomations().map((entry) => entry.id)).toEqual(['ssh-1-a', 'orphan-1'])
})
it('keeps delete available on an orphan but refuses to dispatch it', async () => {
const store = await createStore()
expect(() =>
store.assertAutomationOwnerFence({ id: 'orphan-1', operation: 'execute' })
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }))
store.deleteAutomation('orphan-1', { expectedOwner: { selector: { kind: 'orphan' } } })
expect(store.listAutomations().map((entry) => entry.id)).toEqual(['local-1', 'ssh-1-a'])
})
it('rejects a create whose destination is not a current registration', async () => {
const store = await createStore()
const input = {
name: 'New',
prompt: 'go',
agentId: 'claude' as const,
projectId: 'repo-ssh',
workspaceMode: 'new_per_run' as const,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0
}
expect(() =>
store.createAutomation(input, {
destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } }
})
).toThrowError(
expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination })
)
expect(() =>
store.createAutomation(input, { destination: { selector: { kind: 'self' } } })
).toThrowError(
expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination })
)
const created = store.createAutomation(input, {
destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }
})
expect(created.executionTargetGeneration).toBe(7)
})
})
/**
* A client with no target registry of its own reads the owner, then acts on it.
* The projection is what makes the fence satisfiable off-host rather than a wall.
*/
describe('projected owner precondition', () => {
it('projects the fenceable owner for each kind of record', async () => {
const store = await createStore()
expect(store.automationOwnerPrecondition('ssh-1-a')).toEqual(OWNED_SSH)
expect(store.automationOwnerPrecondition('local-1')).toEqual({ selector: { kind: 'self' } })
// The orphan issue is diagnosis, not identity, so it stays out of the precondition.
expect(store.automationOwnerPrecondition('orphan-1')).toEqual({
selector: { kind: 'orphan' }
})
expect(store.automationOwnerPrecondition('missing')).toBeNull()
})
it('satisfies the fence it was projected from, on the record that used to refuse', async () => {
const store = await createStore()
const owner = store.automationOwnerPrecondition('ssh-1-a')!
expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError(
expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired })
)
expect(
store.updateAutomation('ssh-1-a', { enabled: false }, { expectedOwner: owner }).enabled
).toBe(false)
})
// Execute is refused whatever the caller names: an orphan has no host to run on.
it('does not let an orphan precondition unlock a dispatch', async () => {
const store = await createStore()
const owner = store.automationOwnerPrecondition('orphan-1')!
expect(() =>
store.assertAutomationOwnerFence({
id: 'orphan-1',
expectedOwner: owner,
operation: 'execute'
})
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }))
store.deleteAutomation('orphan-1', { expectedOwner: owner })
expect(store.listAutomations().map((entry) => entry.id)).not.toContain('orphan-1')
})
})
@@ -0,0 +1,487 @@
import { describe, expect, it } from 'vitest'
import { migrateAutomationOwners } from './automation-owner-migration'
import {
AUTOMATION_ORPHAN_ISSUES,
projectAutomationSelector
} from '../../shared/automation-list-scope'
import type { Automation } from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import { folderWorkspaceKey } from '../../shared/workspace-scope'
const NOW = 1_700_000_000_000
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW,
...overrides
}
}
function makeTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'ssh-1',
label: 'Dev box',
host: 'dev.example.com',
port: 22,
username: 'tim',
...overrides
}
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/tmp/repo',
displayName: 'Repo One',
badgeColor: '#fff',
addedAt: NOW,
...overrides
}
}
function migrate(input: {
automations?: Automation[]
sshTargets?: SshTarget[]
repos?: Repo[]
folderWorkspaces?: FolderWorkspace[]
projectGroups?: ProjectGroup[]
sshTargetGenerationCounter?: number
}) {
return migrateAutomationOwners({
automations: input.automations ?? [],
sshTargets: input.sshTargets ?? [],
repos: input.repos ?? [],
folderWorkspaces: input.folderWorkspaces,
projectGroups: input.projectGroups,
removedSshTargetTombstones: [],
sshTargetGenerationCounter: input.sshTargetGenerationCounter,
now: NOW
})
}
/** A folder workspace rooted on an SSH host: its scope connection pins every record inside it. */
function pinnedWorkspaceState(): {
folderWorkspaces: FolderWorkspace[]
projectGroups: ProjectGroup[]
} {
return {
projectGroups: [
{
id: 'group-1',
name: 'Group',
parentPath: '/srv',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
}
],
folderWorkspaces: [
{
id: 'fw-1',
projectGroupId: 'group-1',
name: 'Remote root',
folderPath: '/srv/remote',
connectionId: 'ssh-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
createdAt: NOW,
updatedAt: NOW
}
]
}
}
/** The same workspace after its execution host was changed outside the automation editor. */
function repinnedWorkspaceState(connectionId: string): {
folderWorkspaces: FolderWorkspace[]
projectGroups: ProjectGroup[]
} {
const state = pinnedWorkspaceState()
return {
...state,
folderWorkspaces: state.folderWorkspaces.map((workspace) => ({ ...workspace, connectionId }))
}
}
function pinnedLocalAutomation(overrides: Partial<Automation> = {}): Automation {
return makeAutomation({
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1'),
...overrides
})
}
describe('migrateAutomationOwners', () => {
it('stamps generations on legacy targets and lets a live SSH automation adopt one', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
schedulerOwner: 'ssh_bridge'
})
],
sshTargets: [makeTarget()]
})
expect(result.changed).toBe(true)
expect(result.sshTargets[0].generation).toBe(1)
expect(result.automations[0].executionTargetGeneration).toBe(1)
expect(result.automations[0].enabled).toBe(true)
expect(result.sshTargetGenerationCounter).toBe(1)
})
it('is idempotent', () => {
const first = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
schedulerOwner: 'ssh_bridge'
}),
makeAutomation({ id: 'auto-2', executionTargetType: 'ssh', executionTargetId: 'ssh-gone' })
],
sshTargets: [makeTarget()],
repos: [makeRepo()]
})
const second = migrateAutomationOwners({
automations: first.automations,
sshTargets: first.sshTargets,
repos: [makeRepo()],
removedSshTargetTombstones: first.removedSshTargetTombstones,
sshTargetGenerationCounter: first.sshTargetGenerationCounter,
now: NOW + 1
})
expect(second.changed).toBe(false)
expect(second.automations).toEqual(first.automations)
expect(second.sshTargets).toEqual(first.sshTargets)
expect(second.removedSshTargetTombstones).toEqual(first.removedSshTargetTombstones)
})
it('turns an automation whose target is gone into a disabled orphan without rewriting it to local', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
schedulerOwner: 'ssh_bridge'
})
],
repos: [makeRepo({ connectionId: 'ssh-gone' })]
})
const migrated = result.automations[0]
expect(migrated.enabled).toBe(false)
expect(migrated.executionTargetType).toBe('ssh')
expect(migrated.executionTargetId).toBe('ssh-gone')
expect(migrated.executionTargetGeneration).toBeUndefined()
expect(migrated.schedulerOwner).toBe('ssh_bridge')
})
it('records a synthetic tombstone carrying the last known label for a ghost target', () => {
const result = migrate({
automations: [makeAutomation({ executionTargetType: 'ssh', executionTargetId: 'ssh-gone' })],
repos: [makeRepo({ connectionId: 'ssh-gone' })]
})
expect(result.removedSshTargetTombstones).toEqual([
{
oldTargetId: 'ssh-gone',
host: '',
port: 0,
username: '',
label: 'Repo One',
removedAt: NOW,
origin: 'automation-scan'
}
])
})
it('falls back to the target id when no repo supplies a label', () => {
const result = migrate({
automations: [makeAutomation({ executionTargetType: 'ssh', executionTargetId: 'ssh-gone' })]
})
expect(result.removedSshTargetTombstones[0].label).toBe('ssh-gone')
})
it('does not overwrite an existing real tombstone with a synthetic one', () => {
const result = migrateAutomationOwners({
automations: [makeAutomation({ executionTargetType: 'ssh', executionTargetId: 'ssh-gone' })],
sshTargets: [],
repos: [],
removedSshTargetTombstones: [
{
oldTargetId: 'ssh-gone',
host: 'old.example.com',
port: 22,
username: 'tim',
label: 'Old box',
removedAt: 1
}
],
sshTargetGenerationCounter: 0,
now: NOW
})
expect(result.removedSshTargetTombstones).toHaveLength(1)
expect(result.removedSshTargetTombstones[0].origin).toBeUndefined()
})
it.each([
{ label: 'schedulerOwner', overrides: { schedulerOwner: 'remote_host_service' as const } },
{
label: 'runContext.hostId',
overrides: {
runContext: {
kind: 'workspace-run' as const,
projectId: 'p',
hostId: 'runtime:env-1' as const,
projectHostSetupId: 's',
repoId: 'repo-1',
path: '/tmp/repo'
}
}
}
])(
'disables an ambiguous desktop record flagged by $label and leaves it in place',
({ overrides }) => {
const result = migrate({ automations: [makeAutomation(overrides)] })
const migrated = result.automations[0]
expect(migrated.enabled).toBe(false)
expect(migrated.executionTargetType).toBe('local')
expect(migrated.executionTargetId).toBe('local')
expect(result.automations).toHaveLength(1)
}
)
it('leaves a differing captured generation alone so the record stays a replaced orphan', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
schedulerOwner: 'ssh_bridge',
executionTargetGeneration: 4
})
],
sshTargets: [makeTarget({ generation: 9 })],
sshTargetGenerationCounter: 9
})
const migrated = result.automations[0]
expect(migrated.executionTargetGeneration).toBe(4)
expect(
projectAutomationSelector(migrated, {
sshTargetGeneration: () => 9,
repoConnectionId: () => 'ssh-1'
})
).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced })
})
it('never re-disables a record whose enablement the user decided', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
schedulerOwner: 'ssh_bridge',
enabledDecidedBy: 'user'
})
],
repos: [makeRepo({ connectionId: 'ssh-gone' })]
})
expect(result.automations[0].enabled).toBe(true)
})
// Reachable through a downgrade: an older build re-enables without knowing the stamp.
it('does not revisit a record it has already decided once', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
enabledDecidedBy: 'owner_migration'
})
]
})
expect(result.automations[0].enabled).toBe(true)
})
it('leaves a healthy local automation untouched', () => {
const result = migrate({ automations: [makeAutomation()] })
expect(result.changed).toBe(false)
expect(result.automations[0].enabled).toBe(true)
})
it('preserves unknown persisted fields on migrated records and targets', () => {
const automation = {
...makeAutomation({ executionTargetType: 'ssh', executionTargetId: 'ssh-1' }),
futureField: 'keep'
}
const target = { ...makeTarget(), futureTargetField: 'keep' }
const result = migrate({
automations: [automation as Automation],
sshTargets: [target as SshTarget]
})
expect(result.automations[0]).toMatchObject({ futureField: 'keep' })
expect(result.sshTargets[0]).toMatchObject({ futureTargetField: 'keep' })
})
it('backfills the pinned registration for a local record its folder workspace puts on SSH', () => {
const result = migrate({
automations: [pinnedLocalAutomation()],
sshTargets: [makeTarget({ generation: 4 })],
sshTargetGenerationCounter: 4,
...pinnedWorkspaceState()
})
expect(result.changed).toBe(true)
expect(result.automations[0].executionTargetType).toBe('local')
expect(result.automations[0].executionTargetGeneration).toBe(4)
})
it('never overwrites a differing capture on a pinned record, so it stays a replaced orphan', () => {
const workspace = pinnedWorkspaceState()
const result = migrate({
automations: [pinnedLocalAutomation({ executionTargetGeneration: 4 })],
sshTargets: [makeTarget({ generation: 9 })],
sshTargetGenerationCounter: 9,
...workspace
})
const migrated = result.automations[0]
expect(migrated.executionTargetGeneration).toBe(4)
expect(
projectAutomationSelector(migrated, {
sshTargetGeneration: () => 9,
repoConnectionId: () => null,
workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' })
})
).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced })
})
it('is idempotent for pinned records', () => {
const workspace = pinnedWorkspaceState()
const first = migrate({
automations: [pinnedLocalAutomation()],
sshTargets: [makeTarget({ generation: 4 })],
sshTargetGenerationCounter: 4,
...workspace
})
const second = migrateAutomationOwners({
automations: first.automations,
sshTargets: first.sshTargets,
repos: [],
...workspace,
removedSshTargetTombstones: first.removedSshTargetTombstones,
sshTargetGenerationCounter: first.sshTargetGenerationCounter,
now: NOW + 1
})
expect(second.changed).toBe(false)
expect(second.automations).toEqual(first.automations)
})
it('leaves an unpinned local record without a capture', () => {
const result = migrate({
automations: [makeAutomation()],
sshTargets: [makeTarget({ generation: 4 })],
sshTargetGenerationCounter: 4,
...pinnedWorkspaceState()
})
expect(result.automations[0].executionTargetGeneration).toBeUndefined()
})
// The pin, not the project, is what dispatch resolves, so a pin this authority cannot
// vouch for has to classify the same way an unresolvable SSH selector does.
it('disables a pinned local record whose pin names an unregistered target', () => {
const result = migrate({
automations: [pinnedLocalAutomation()],
sshTargets: [],
...pinnedWorkspaceState()
})
const migrated = result.automations[0]
expect(migrated.enabled).toBe(false)
expect(migrated.enabledDecidedBy).toBe('owner_migration')
expect(migrated.executionTargetType).toBe('local')
expect(migrated.executionTargetId).toBe('local')
})
it('disables a pinned local record whose pin came back as a different registration', () => {
const result = migrate({
automations: [pinnedLocalAutomation({ executionTargetGeneration: 4 })],
sshTargets: [makeTarget({ generation: 9 })],
sshTargetGenerationCounter: 9,
...pinnedWorkspaceState()
})
const migrated = result.automations[0]
expect(migrated.enabled).toBe(false)
expect(migrated.executionTargetGeneration).toBe(4)
})
// Absence of evidence: with no workspace list supplied, nothing proves the record is pinned.
it('leaves a record alone when no workspace list was supplied to prove a pin', () => {
const result = migrate({ automations: [pinnedLocalAutomation()], sshTargets: [] })
expect(result.automations[0].enabled).toBe(true)
})
it('follows an external re-pin onto the registration the workspace names now', () => {
const result = migrate({
automations: [pinnedLocalAutomation({ executionTargetGeneration: 4 })],
sshTargets: [makeTarget({ generation: 4 }), makeTarget({ id: 'ssh-2', generation: 9 })],
sshTargetGenerationCounter: 9,
...repinnedWorkspaceState('ssh-2')
})
const migrated = result.automations[0]
expect(migrated.executionTargetGeneration).toBe(9)
expect(migrated.enabled).toBe(true)
expect(
projectAutomationSelector(migrated, {
sshTargetGeneration: (targetId) => (targetId === 'ssh-2' ? 9 : 4),
repoConnectionId: () => null,
workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-2' })
})
).toEqual({ kind: 'ssh', targetId: 'ssh-2', targetGeneration: 9 })
})
it('cannot reissue a generation an automation already captured after a counter rollback', () => {
const result = migrate({
automations: [
makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
executionTargetGeneration: 9
})
],
sshTargets: [makeTarget({ id: 'ssh-new' })],
sshTargetGenerationCounter: 2
})
expect(result.sshTargets[0].generation).toBe(10)
expect(result.sshTargetGenerationCounter).toBe(10)
})
})
@@ -0,0 +1,246 @@
import type { Automation } from '../../shared/automations-types'
import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import { parseExecutionHostId } from '../../shared/execution-host'
import {
resolveAutomationWorkspaceSshTargetId,
type AutomationWorkspaceSshPin
} from '../../shared/automation-workspace-pin'
import {
isWorkspaceSshPinRepinned,
type SshTargetIdForGeneration
} from '../../shared/automation-workspace-repin'
import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host'
import {
nextSshTargetGeneration,
resolveSshTargetGenerationHighWaterMark,
sanitizeSshTargetGeneration
} from '../../shared/ssh-target-generation'
import { scanAutomationsForGhostSshTargets } from './automation-ghost-ssh-tombstones'
/**
* One idempotent load-time migration that gives every stored automation a
* fenceable owner. Order is load-bearing: ghost tombstones are recorded from the
* pre-migration automations first (so a dead target still has a label), then
* generations are stamped on live targets, then automations adopt or are
* classified. Running it twice changes nothing.
*/
export type AutomationOwnerClassification = 'owned' | 'orphan' | 'ambiguous'
/**
* Storage authority is never inferred from these fields — they only mark a record unrunnable.
*
* `knownSshTargetIds` is the authority's own persisted registry, so a miss is
* settled evidence rather than a list that has not loaded. `workspacePin` is
* likewise only supplied once a workspace has been resolved: an absent pin means
* nothing proves the record is pinned, never that its host is gone.
*/
export function classifyAutomationOwner(
automation: Automation,
knownSshTargetIds: ReadonlySet<string>,
workspacePin?: AutomationWorkspaceSshPin
): AutomationOwnerClassification {
if (
automation.schedulerOwner === 'remote_host_service' ||
parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime'
) {
return 'ambiguous'
}
if (automation.executionTargetType !== 'ssh') {
// The pin is where the run goes, so a pin this authority cannot vouch for is the same
// unrunnable record dispatch refuses — classifying it `owned` left it enabled and editable.
if (!workspacePin) {
return 'owned'
}
const captured = sanitizeSshTargetGeneration(automation.executionTargetGeneration)
return knownSshTargetIds.has(workspacePin.targetId) &&
(captured === undefined || captured === workspacePin.generation)
? 'owned'
: 'orphan'
}
return automation.executionTargetId && knownSshTargetIds.has(automation.executionTargetId)
? 'owned'
: 'orphan'
}
export type AutomationOwnerMigrationInput = {
automations: readonly Automation[]
sshTargets: readonly SshTarget[]
repos: readonly Repo[]
/** Needed to resolve which SSH target a record's folder workspace pins it to. */
folderWorkspaces?: readonly FolderWorkspace[]
projectGroups?: readonly ProjectGroup[]
removedSshTargetTombstones: readonly RemovedSshTargetTombstone[]
sshTargetGenerationCounter?: number
now: number
}
export type AutomationOwnerMigrationResult = {
automations: Automation[]
sshTargets: SshTarget[]
removedSshTargetTombstones: RemovedSshTargetTombstone[]
sshTargetGenerationCounter: number
changed: boolean
}
type StampedTargets = { targets: SshTarget[]; counter: number; changed: boolean }
function stampTargetGenerations(
sshTargets: readonly SshTarget[],
highWaterMark: number
): StampedTargets {
let counter = highWaterMark
let changed = false
const targets = sshTargets.map((target) => {
if (sanitizeSshTargetGeneration(target.generation) !== undefined) {
return target
}
counter = nextSshTargetGeneration(counter)
changed = true
return { ...target, generation: counter }
})
return { targets, counter, changed }
}
type MigratedAutomations = { automations: Automation[]; changed: boolean }
/**
* Only an absent capture is stamped: overwriting a differing one would re-adopt
* the record onto the target that replaced the incarnation it was attached to.
*/
function stampCapturedGeneration(
automation: Automation,
ownerTargetId: string | undefined,
targetsById: ReadonlyMap<string, SshTarget>
): Automation {
const generation =
ownerTargetId === undefined ? undefined : targetsById.get(ownerTargetId)?.generation
if (
generation === undefined ||
sanitizeSshTargetGeneration(automation.executionTargetGeneration) !== undefined
) {
return automation
}
return { ...automation, executionTargetGeneration: generation }
}
/**
* A local record its workspace pins to SSH runs on that registration, so it is
* fenced by it too — the pin owns the capture the way the selector does for SSH rows.
*/
function resolveWorkspacePin(
automation: Automation,
workspaceState: FolderWorkspaceHostState,
targetsById: ReadonlyMap<string, SshTarget>
): AutomationWorkspaceSshPin | undefined {
if (automation.executionTargetType === 'ssh') {
return undefined
}
const targetId = resolveAutomationWorkspaceSshTargetId(workspaceState, automation.workspaceId)
return targetId === undefined
? undefined
: { targetId, generation: targetsById.get(targetId)?.generation }
}
/** A re-pinned workspace moved the record's host, so the capture moves with it. */
function followWorkspaceRepin(
automation: Automation,
pin: AutomationWorkspaceSshPin | undefined,
sshTargetIdForGeneration: SshTargetIdForGeneration
): Automation {
return isWorkspaceSshPinRepinned({
capturedGeneration: automation.executionTargetGeneration,
pin,
sshTargetIdForGeneration
})
? { ...automation, executionTargetGeneration: pin?.generation }
: automation
}
function indexTargetsByGeneration(
targetsById: ReadonlyMap<string, SshTarget>
): SshTargetIdForGeneration {
const owners = new Map<number, string>()
for (const target of targetsById.values()) {
const generation = sanitizeSshTargetGeneration(target.generation)
if (generation !== undefined) {
owners.set(generation, target.id)
}
}
return (generation) => owners.get(generation)
}
function migrateAutomations(
automations: readonly Automation[],
targetsById: ReadonlyMap<string, SshTarget>,
workspaceState: FolderWorkspaceHostState
): MigratedAutomations {
const knownIds = new Set(targetsById.keys())
const sshTargetIdForGeneration = indexTargetsByGeneration(targetsById)
let changed = false
const migrated = automations.map((automation) => {
const pin = resolveWorkspacePin(automation, workspaceState, targetsById)
// Before classification: a followed re-pin is a healthy record, not an orphan.
const record = followWorkspaceRepin(automation, pin, sshTargetIdForGeneration)
const classification = classifyAutomationOwner(record, knownIds, pin)
// Orphans and ambiguous records must not keep firing on a guessed host, but
// they are never moved, rewritten to Self, or deleted.
if (classification !== 'owned') {
// Once per record, not once per load: a stamp means the decision is already
// made — by this migration, or by a user whose re-enable outranks it.
if (!record.enabled || record.enabledDecidedBy) {
changed ||= record !== automation
return record
}
changed = true
return { ...record, enabled: false, enabledDecidedBy: 'owner_migration' as const }
}
const ownerTargetId =
record.executionTargetType === 'ssh' ? record.executionTargetId : pin?.targetId
const stamped = stampCapturedGeneration(record, ownerTargetId, targetsById)
if (stamped !== automation) {
changed = true
}
return stamped
})
return { automations: migrated, changed }
}
export function migrateAutomationOwners(
input: AutomationOwnerMigrationInput
): AutomationOwnerMigrationResult {
const tombstoneScan = scanAutomationsForGhostSshTargets({
automations: input.automations,
sshTargets: input.sshTargets,
repos: input.repos,
removedSshTargetTombstones: input.removedSshTargetTombstones,
now: input.now
})
const highWaterMark = resolveSshTargetGenerationHighWaterMark({
persistedCounter: input.sshTargetGenerationCounter,
targetGenerations: input.sshTargets.map((target) => target.generation),
capturedGenerations: input.automations.map((automation) => automation.executionTargetGeneration)
})
const stamped = stampTargetGenerations(input.sshTargets, highWaterMark)
const targetsById = new Map(stamped.targets.map((target) => [target.id, target]))
const migrated = migrateAutomations(input.automations, targetsById, {
folderWorkspaces: [...(input.folderWorkspaces ?? [])],
projectGroups: [...(input.projectGroups ?? [])],
repos: [...input.repos]
})
return {
automations: migrated.automations,
sshTargets: stamped.targets,
removedSshTargetTombstones: tombstoneScan.removedSshTargetTombstones,
sshTargetGenerationCounter: stamped.counter,
changed:
tombstoneScan.changed ||
stamped.changed ||
migrated.changed ||
// A rolled-back counter must be rewritten even when nothing else changed.
stamped.counter !== (sanitizeSshTargetGeneration(input.sshTargetGenerationCounter) ?? 0)
}
}
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import { createRuntimeAutomationRunTerminalObserver } from './runtime-terminal-run-observer'
import type { AutomationRun } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
const repo: Repo = {
id: 'repo-1',
path: '/tmp/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git'
}
const TAB_ID = 'tab-1'
const LEAF_ID = '11111111-2222-4333-8444-555555555555'
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
const WORKTREE_ID = '/tmp/orca::wt1'
function makeStore() {
return {
getRepos: vi.fn(() => [repo]),
listAutomations: vi.fn(() => []),
listAutomationRuns: vi.fn(() => []),
getSettings: vi.fn(() => ({
workspaceDir: '/tmp',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: '',
branchPrefixCustom: ''
})),
getAllWorktreeMeta: vi.fn(() => new Map()),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn(),
getGitHubCache: vi.fn()
}
}
const retainedRun = {
id: 'run-1',
automationId: 'auto-1',
terminalPaneKey: PANE_KEY,
status: 'dispatched'
} as unknown as AutomationRun
/**
* The gate the startup fix depends on: an empty window graph and a bound PTY are
* two different answers, and only the second is evidence about the terminal.
*/
describe('resolving an automation run terminal across startup states', () => {
it('cannot answer for any pane while no graph has published', () => {
const runtime = new OrcaRuntimeService(makeStore() as never)
const observer = createRuntimeAutomationRunTerminalObserver(runtime)
expect(observer.resolveRunTerminal(retainedRun)).toBeNull()
})
it('still cannot answer once an explicitly empty headless graph publishes', () => {
// Serve publishes exactly this graph before arming automations, so leaves are
// empty there too — serve is not saved by graph ordering alone.
const runtime = new OrcaRuntimeService(makeStore() as never)
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
const observer = createRuntimeAutomationRunTerminalObserver(runtime)
expect(observer.resolveRunTerminal(retainedRun)).toBeNull()
})
it('answers from an adopted PTY bound to the pane, with no leaves at all', () => {
// What serve's pre-start refreshRestoredOrchestrationAuthority produces: a
// ptysById record carrying the restored paneKey, never a leaf.
const runtime = new OrcaRuntimeService(makeStore() as never)
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
runtime.registerPty('pty-1', WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID })
const observer = createRuntimeAutomationRunTerminalObserver(runtime)
expect(observer.resolveRunTerminal(retainedRun)).toBeTruthy()
})
it('answers only after the pane binds, so an early lookup is not a lost terminal', () => {
const runtime = new OrcaRuntimeService(makeStore() as never)
const observer = createRuntimeAutomationRunTerminalObserver(runtime)
expect(observer.resolveRunTerminal(retainedRun)).toBeNull()
runtime.registerPty('pty-1', WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID })
expect(observer.resolveRunTerminal(retainedRun)).toBeTruthy()
})
})
@@ -0,0 +1,88 @@
/**
* Run and usage writes are the highest-frequency events in the system, so they
* carry the host they belong to: an unscoped one refetches every host in the
* catalog.
*/
import { describe, expect, it, vi } from 'vitest'
import { createAutomationRunWriter } from './automation-run-writer'
import { AutomationService } from './service'
import type { Store } from '../persistence'
import type { Automation, AutomationRun } from '../../shared/automations-types'
const SSH_SELECTOR = { kind: 'ssh', targetId: 'ssh-1' } as const
function writerWith(selector: ReturnType<Store['automationChangeSelector']>) {
const publish = vi.fn()
const automationChangeSelector = vi.fn(() => selector)
const store = {
createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
updateAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
automationChangeSelector
} as unknown as Store
return {
publish,
automationChangeSelector,
writer: createAutomationRunWriter(store, publish),
store
}
}
describe('automation run writer publications', () => {
it('names the host a created run belongs to', () => {
const { writer, publish } = writerWith(SSH_SELECTOR)
writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
})
it('resolves the host from the written run, which is all a dispatch result names', () => {
const { writer, publish, automationChangeSelector } = writerWith(SSH_SELECTOR)
writer.updateRun({ runId: 'run-1', status: 'completed', usage: null })
expect(automationChangeSelector).toHaveBeenCalledWith('auto-1')
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
})
it('keeps the usage reason on a usage-bearing write', () => {
const { writer, publish } = writerWith({ kind: 'self' })
writer.updateRun({
runId: 'run-1',
status: 'completed',
usage: { status: 'known' } as AutomationRun['usage']
})
expect(publish).toHaveBeenCalledWith({ reason: 'usage', selector: { kind: 'self' } })
})
// Over-broad beats silent: a subscriber must still hear that something changed.
it('falls back to the whole authority when the record can no longer be named', () => {
const { writer, publish } = writerWith(null)
writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
expect(publish).toHaveBeenCalledWith({ reason: 'run' })
})
it('does not project a selector nobody will hear', () => {
const automationChangeSelector = vi.fn(() => SSH_SELECTOR)
const store = {
createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
automationChangeSelector
} as unknown as Store
createAutomationRunWriter(store, null).createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
expect(automationChangeSelector).not.toHaveBeenCalled()
})
// Why the renderer dispatch path no longer emits its own: the scoped event is
// published during the write, so it is queued before the reply the caller awaits.
it('publishes before markDispatchResult settles', async () => {
const publish = vi.fn()
const store = {
updateAutomationRun: vi.fn(
() => ({ id: 'run-1', automationId: 'auto-1', status: 'dispatched' }) as AutomationRun
),
automationChangeSelector: vi.fn(() => SSH_SELECTOR)
} as unknown as Store
const service = new AutomationService(store, { onAutomationsChanged: publish })
const settled = service.markDispatchResult({ runId: 'run-1', status: 'dispatched' })
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
await settled
})
})
@@ -0,0 +1,47 @@
import type { Store } from '../persistence'
import type { PublishAutomationsChanged } from '../../shared/runtime-client-events'
import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types'
export type AutomationRunWriter = {
createRun: Store['createAutomationRun']
updateRun: Store['updateAutomationRun']
/** Null when nothing could be folded — the caller then writes an ordinary run. */
repeatSkip: Store['recordRepeatedAutomationSkip']
}
/** Wraps run persistence so every committed write announces itself. Clients with
* the Automations page closed — or none attached at all — have no other way to
* learn that a run progressed, so the event must follow the write, not a render. */
export function createAutomationRunWriter(
store: Store,
publish: PublishAutomationsChanged | null
): AutomationRunWriter {
// A run write never moves the record, so its own host is the whole publication.
// A record that can no longer be named degrades to the authority-wide event.
const announce = (automationId: string, reason: 'run' | 'usage'): void => {
if (!publish) {
return
}
const selector = store.automationChangeSelector(automationId)
publish({ reason, ...(selector ? { selector } : {}) })
}
return {
createRun: (automation, scheduledFor, trigger): AutomationRun => {
const run = store.createAutomationRun(automation, scheduledFor, trigger)
announce(automation.id, 'run')
return run
},
updateRun: (result: AutomationDispatchResult): AutomationRun => {
const run = store.updateAutomationRun(result)
announce(run.automationId, result.usage ? 'usage' : 'run')
return run
},
repeatSkip: (automationId, error, scheduledFor): AutomationRun | null => {
const run = store.recordRepeatedAutomationSkip(automationId, error, scheduledFor)
if (run) {
announce(automationId, 'run')
}
return run
}
}
}
@@ -0,0 +1,299 @@
/**
* A target that cannot resolve refuses every occurrence, and the refusal record
* must stay — a user who sees nothing concludes the feature is broken. What must
* not stay is one row per occurrence: retention keeps 100 runs per automation, so
* a five-minute schedule would bury its real history in under a day.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import type { Automation } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { AutomationService } from './service'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
async function createStore(): Promise<Store> {
vi.resetModules()
const { Store: StoreClass, initDataPath } = await import('../persistence')
initDataPath()
return new StoreClass()
}
const makeRepo = (): Repo => ({
id: 'r1',
path: '/repo',
displayName: 'test',
badgeColor: '#fff',
addedAt: 1
})
const SETUP_GONE = 'Project is not set up on the selected automation host anymore.'
const NO_WINDOW = 'No Orca window was available to launch the automation.'
/** Daily at 09:00, pointed at a project host setup that does not exist. */
function seedUnresolvableAutomation(store: Store): Automation {
store.addRepo(makeRepo())
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'local',
projectHostSetupId: 'missing-setup',
repoId: 'r1',
path: '/repo'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
}
/** Daily at 09:00 against a repo that resolves, so only the window can refuse it. */
function seedRunnableAutomation(store: Store): Automation {
store.addRepo(makeRepo())
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
}
function attachedService(store: Store): AutomationService {
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({ isDestroyed: () => false, send: vi.fn() } as never)
return service
}
/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */
async function evaluateAt(
store: Store,
service: AutomationService,
automationId: string,
when: string
): Promise<void> {
const nextRunAt = (): number | undefined =>
store.listAutomations().find((entry) => entry.id === automationId)?.nextRunAt
const before = nextRunAt()
vi.setSystemTime(new Date(when))
service.setRendererReady()
await vi.waitFor(() => expect(nextRunAt()).toBeGreaterThan(before ?? 0))
}
function seedSkip(store: Store, automation: Automation, scheduledFor: number, error: string): void {
const run = store.createAutomationRun(automation, scheduledFor)
store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: null,
error
})
}
describe('repeated skipped_unavailable coalescing', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-skip-coalescing-test-'))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('folds every later refusal into the first record instead of writing a row each', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const service = attachedService(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00')
const runs = store.listAutomationRuns(automation.id)
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
status: 'skipped_unavailable',
error: SETUP_GONE,
occurrenceCount: 3,
// The record keeps the first occurrence and names the latest it stands for.
scheduledFor: new Date('2026-05-13T09:00:00').getTime(),
lastOccurrenceAt: new Date('2026-05-15T09:00:00').getTime()
})
})
/**
* Closing the window on macOS keeps the scheduler running — nothing stops it
* until quit — so this refusal repeats on the schedule's own cadence and must
* fold like any other, or a five-minute automation buries its history in ~8h.
*/
it('folds the refusals a closed window produces instead of writing a row each', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedRunnableAutomation(store)
const service = attachedService(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
service.setWebContents(null)
await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-16T09:01:00')
const runs = store.listAutomationRuns(automation.id)
expect(runs.map((run) => run.status)).toEqual(['skipped_unavailable', 'dispatching'])
expect(runs[0]).toMatchObject({
error: NO_WINDOW,
occurrenceCount: 3,
lastOccurrenceAt: new Date('2026-05-16T09:00:00').getTime()
})
})
it('starts a new record when a real run intervened, so history is not rewritten', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const completed = store.createAutomationRun(
automation,
new Date('2026-05-12T09:00:00').getTime()
)
store.updateAutomationRun({ runId: completed.id, status: 'completed', workspaceId: null })
const service = attachedService(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00')
const runs = store.listAutomationRuns(automation.id)
expect(runs.map((run) => run.status)).toEqual(['skipped_unavailable', 'completed'])
expect(runs[0].occurrenceCount).toBe(2)
})
it('gives a manual attempt its own answer rather than folding it into a scheduled skip', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const service = attachedService(store)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
const manual = await service.runNow(automation.id)
expect(manual.status).toBe('skipped_unavailable')
expect(manual.occurrenceCount).toBeUndefined()
expect(store.listAutomationRuns(automation.id)).toHaveLength(2)
})
it('never folds a scheduled refusal into a manual one', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const manual = store.createAutomationRun(automation, Date.now(), 'manual')
store.updateAutomationRun({
runId: manual.id,
status: 'skipped_unavailable',
workspaceId: null,
error: SETUP_GONE
})
expect(
store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, Date.now() + 1000)
).toBeNull()
})
// Merging these would destroy the signal the record exists to carry.
it('keeps distinct diagnoses in distinct records', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
seedSkip(store, automation, Date.now(), 'The SSH host is unreachable.')
expect(
store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, Date.now() + 1000)
).toBeNull()
})
it('counts one occurrence once, however many times the scheduler revisits it', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const scheduledFor = Date.now()
seedSkip(store, automation, scheduledFor, SETUP_GONE)
expect(
store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor)?.occurrenceCount
).toBeUndefined()
const folded = store.recordRepeatedAutomationSkip(
automation.id,
SETUP_GONE,
scheduledFor + 1000
)
expect(folded?.occurrenceCount).toBe(2)
expect(
store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 1000)
?.occurrenceCount
).toBe(2)
})
it('announces the fold, so a client that is not polling still sees the repeat', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const published: unknown[] = []
const service = new AutomationService(store, {
tickMs: 60_000,
onAutomationsChanged: (payload) => published.push(payload)
})
service.setWebContents({ isDestroyed: () => false, send: vi.fn() } as never)
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
published.length = 0
await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00')
expect(published).toEqual([{ reason: 'run', selector: { kind: 'self' } }])
})
// Rollback shape: a build that never heard of these fields writes plain rows and
// preserves stamped ones, so an upgrade resumes counting instead of restarting.
it('resumes counting a folded record across a reload', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore()
const automation = seedUnresolvableAutomation(store)
const scheduledFor = Date.now()
seedSkip(store, automation, scheduledFor, SETUP_GONE)
store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 1000)
store.flush()
const { Store: StoreClass } = await import('../persistence')
const reloaded = new StoreClass()
expect(reloaded.listAutomationRuns(automation.id)[0]?.occurrenceCount).toBe(2)
expect(
reloaded.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 2000)
?.occurrenceCount
).toBe(3)
})
})
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest'
import {
migrateAutomationHostFilterSshTargetId,
migrateAutomationsForSshReadoption
} from './automation-ssh-readoption-migration'
import { hostStableKey } from '../../shared/automation-owner-key'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
const NOW = 1_700_000_000_000
const OLD_ID = 'ssh-1738-a9f3x'
const NEW_ID = 'ssh-1799-b2k7z'
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'ssh',
executionTargetId: OLD_ID,
executionTargetGeneration: 3,
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW,
...overrides
}
}
function migrate(
automations: Automation[],
generation: number | undefined,
runs: AutomationRun[] = []
) {
return migrateAutomationsForSshReadoption({
automations,
automationRuns: runs,
oldTargetId: OLD_ID,
newTargetId: NEW_ID,
newTargetGeneration: generation
})
}
function runContext(hostId: string) {
return {
kind: 'workspace-run' as const,
projectId: 'project-1',
hostId: hostId as `ssh:${string}`,
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/srv/repo'
}
}
function uiWithFilter(hostKey: string): PersistedUIState {
return { automationHostFilter: { kind: 'host', hostKey } } as PersistedUIState
}
describe('migrateAutomationsForSshReadoption', () => {
it('re-points the owner and adopts the re-added registration generation', () => {
const automation = makeAutomation()
expect(migrate([automation], 9)).toBe(true)
expect(automation.executionTargetId).toBe(NEW_ID)
expect(automation.executionTargetGeneration).toBe(9)
})
it('drops a stale capture when the re-added target carries no generation', () => {
const automation = makeAutomation()
expect(migrate([automation], undefined)).toBe(true)
expect(automation.executionTargetId).toBe(NEW_ID)
expect('executionTargetGeneration' in automation).toBe(false)
})
it('migrates run and source context host ids, including for local records', () => {
const automation = makeAutomation({
executionTargetType: 'local',
executionTargetId: 'local',
executionTargetGeneration: undefined,
runContext: runContext(`ssh:${OLD_ID}`),
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId: `ssh:${OLD_ID}`
}
})
expect(migrate([automation], 9)).toBe(true)
expect(automation.runContext?.hostId).toBe(`ssh:${NEW_ID}`)
expect(automation.sourceContext?.hostId).toBe(`ssh:${NEW_ID}`)
// A local record's execution target is not an SSH reference and must stay untouched.
expect(automation.executionTargetId).toBe('local')
expect(automation.executionTargetGeneration).toBeUndefined()
})
it('leaves automations on other targets alone', () => {
const other = makeAutomation({ id: 'auto-2', executionTargetId: 'ssh-other' })
expect(migrate([other], 9)).toBe(false)
expect(other.executionTargetId).toBe('ssh-other')
expect(other.executionTargetGeneration).toBe(3)
})
it('re-points run history so workspace navigation survives re-adoption', () => {
const run = {
id: 'run-1',
automationId: 'auto-1',
runContext: runContext(`ssh:${OLD_ID}`)
} as AutomationRun
expect(migrate([], 9, [run])).toBe(true)
expect(run.runContext?.hostId).toBe(`ssh:${NEW_ID}`)
})
})
describe('migrateAutomationHostFilterSshTargetId', () => {
it('follows the re-adopted id', () => {
const ui = uiWithFilter(
hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: OLD_ID } })
)
expect(migrateAutomationHostFilterSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
expect(ui.automationHostFilter).toEqual({
kind: 'host',
hostKey: hostStableKey({
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId: NEW_ID }
})
})
})
it('never rewrites a runtime authority filter that reuses the same target id', () => {
const hostKey = hostStableKey({
authority: { kind: 'runtime', environmentId: 'env-1' },
selector: { kind: 'ssh', targetId: OLD_ID }
})
const ui = uiWithFilter(hostKey)
expect(migrateAutomationHostFilterSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)
expect(ui.automationHostFilter).toEqual({ kind: 'host', hostKey })
})
it('ignores All hosts, other hosts, and unparseable persisted values', () => {
const all = { automationHostFilter: { kind: 'all' } } as PersistedUIState
expect(migrateAutomationHostFilterSshTargetId(all, OLD_ID, NEW_ID)).toBe(false)
const selfKey = hostStableKey({
authority: { kind: 'desktop' },
selector: { kind: 'self' }
})
expect(migrateAutomationHostFilterSshTargetId(uiWithFilter(selfKey), OLD_ID, NEW_ID)).toBe(
false
)
expect(migrateAutomationHostFilterSshTargetId(uiWithFilter('garbage'), OLD_ID, NEW_ID)).toBe(
false
)
// A store written before the filter existed must migrate without throwing.
expect(migrateAutomationHostFilterSshTargetId({} as PersistedUIState, OLD_ID, NEW_ID)).toBe(
false
)
})
})
@@ -0,0 +1,131 @@
import type { Automation, AutomationRun } from '../../shared/automations-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { TaskSourceContext, WorkspaceRunContext } from '../../shared/task-source-context'
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { hostStableKey, parseHostStableKey } from '../../shared/automation-owner-key'
import { sanitizeSshTargetGeneration } from '../../shared/ssh-target-generation'
/**
* The automation half of SSH re-adoption (see ssh/ssh-target-readoption.ts).
*
* `reassignSshTargetId` re-points repos and worktrees onto the re-added target's
* id; the same transaction must move every automation-side reference too, or the
* workspace repairs while its automation stays orphaned on the dead id and the
* persisted host filter keeps naming a host that no longer exists.
*
* The captured generation is rewritten to the re-added registration's rather
* than left to fail closed: an identity-matched removal tombstone plus a live
* registration is exactly the positive current-target evidence the design
* requires, and the value is read from the stored target at migration time — so
* it can never exceed the allocated high-water mark, and an older incarnation
* can never be made to look current.
*/
type HostContextCarrier = {
runContext?: WorkspaceRunContext | null
sourceContext?: TaskSourceContext | null
}
/** Mutates in place, matching how the Store edits `this.state`. */
function migrateContextHostIds(
record: HostContextCarrier,
oldHostId: string,
newHostId: ExecutionHostId
): boolean {
let changed = false
if (record.runContext?.hostId === oldHostId) {
record.runContext.hostId = newHostId
changed = true
}
if (record.sourceContext?.hostId === oldHostId) {
record.sourceContext.hostId = newHostId
changed = true
}
return changed
}
export type AutomationSshReadoptionInput = {
automations: Automation[]
automationRuns: AutomationRun[]
oldTargetId: string
newTargetId: string
/**
* Records the old target owned through their folder workspace rather than their
* selector, collected before the workspace sweep re-points the pin. Their
* capture is the pin's, so re-adoption has to move it with everything else.
*/
workspacePinnedAutomationIds?: ReadonlySet<string>
/** The re-added target's current registration generation, read from stored state. */
newTargetGeneration: number | undefined
}
/** Why: a re-added target with no generation yet must not leave a foreign capture behind — drop it so load-time migration stamps it. */
function rewriteCapturedGeneration(automation: Automation, generation: number | undefined): void {
if (generation === undefined) {
delete automation.executionTargetGeneration
} else {
automation.executionTargetGeneration = generation
}
}
/** Re-point stored automations and their run history onto the re-added target. */
export function migrateAutomationsForSshReadoption(input: AutomationSshReadoptionInput): boolean {
const oldHostId = toSshExecutionHostId(input.oldTargetId)
const newHostId = toSshExecutionHostId(input.newTargetId)
const generation = sanitizeSshTargetGeneration(input.newTargetGeneration)
let changed = false
for (const automation of input.automations) {
if (migrateContextHostIds(automation, oldHostId, newHostId)) {
changed = true
}
if (
automation.executionTargetType !== 'ssh' ||
automation.executionTargetId !== input.oldTargetId
) {
// A workspace-pinned record keeps its local selector; only its capture follows the pin.
if (input.workspacePinnedAutomationIds?.has(automation.id)) {
rewriteCapturedGeneration(automation, generation)
changed = true
}
continue
}
automation.executionTargetId = input.newTargetId
rewriteCapturedGeneration(automation, generation)
changed = true
}
for (const run of input.automationRuns) {
if (migrateContextHostIds(run, oldHostId, newHostId)) {
changed = true
}
}
return changed
}
/** Follow the re-adopted id in the persisted Automations host filter. */
export function migrateAutomationHostFilterSshTargetId(
ui: PersistedUIState,
oldTargetId: string,
newTargetId: string
): boolean {
const filter = ui.automationHostFilter
if (filter?.kind !== 'host') {
return false
}
const host = parseHostStableKey(filter.hostKey)
// Why: a target id is unique only inside one authority, so a runtime's identical id is a different host.
if (
host?.authority.kind !== 'desktop' ||
host.selector.kind !== 'ssh' ||
host.selector.targetId !== oldTargetId
) {
return false
}
ui.automationHostFilter = {
kind: 'host',
hostKey: hostStableKey({
authority: host.authority,
selector: { kind: 'ssh', targetId: newTargetId }
})
}
return true
}
@@ -0,0 +1,248 @@
/**
* An update may not move a record to another host unless it was asked to.
*
* Pause/resume stay enabled on orphans on purpose, so a plain `{ enabled }`
* update is the one mutation an unrunnable record still accepts. Re-deriving the
* execution target from the owning repo on that path re-adopts the record onto
* whatever registration the repo points at today: the user pauses an orphan and
* silently gets a healthy, dispatchable automation on a host they never chose.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'a1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-readded',
executionTargetType: 'ssh',
executionTargetId: 'ssh-old',
executionTargetGeneration: 4,
schedulerOwner: 'ssh_bridge',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
// The user re-enabled it after the owner migration disabled it, so the pause below is real work.
enabled: true,
enabledDecidedBy: 'user',
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const REPOS: Repo[] = [
// Re-adoption re-pointed this repo onto the freshly minted target.
{
id: 'repo-readded',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 1,
connectionId: 'ssh-new'
},
// Same target id back with a new registration: the repo resolves, the capture does not.
{
id: 'repo-same-id',
path: '/remote2',
displayName: 'Remote 2',
badgeColor: '#000',
addedAt: 2,
connectionId: 'ssh-box'
},
{ id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 3 }
] as Repo[]
const TARGETS: SshTarget[] = [
{ id: 'ssh-new', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 },
{ id: 'ssh-box', label: 'Box 2', host: 'box2', port: 22, username: 'me', generation: 9 }
] as SshTarget[]
const AUTOMATIONS: Automation[] = [
automation({ id: 'stranded' }),
automation({
id: 'replaced',
projectId: 'repo-same-id',
executionTargetId: 'ssh-box',
executionTargetGeneration: 4
}),
// Its project is local now, so re-deriving would strip the capture and call it Self.
automation({
id: 'local-project',
projectId: 'repo-local',
schedulerOwner: 'local_host_service'
}),
automation({
id: 'healthy',
projectId: 'repo-same-id',
executionTargetId: 'ssh-box',
executionTargetGeneration: 9
})
]
async function createStore() {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos: REPOS,
sshTargets: TARGETS,
sshTargetGenerationCounter: 9,
automations: AUTOMATIONS
}),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
type Store = Awaited<ReturnType<typeof createStore>>
function storedTarget(record: Automation) {
return {
type: record.executionTargetType,
id: record.executionTargetId,
generation: record.executionTargetGeneration
}
}
function projectedSelector(store: Store, id: string) {
return store.listAutomationsForScope().items.find((item) => item.automationId === id)?.selector
}
const ORPHAN = { selector: { kind: 'orphan' } } as const
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-retarget-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
vi.resetModules()
})
describe('pausing an orphan never re-adopts it', () => {
it('keeps the dead capture when the repo was re-pointed at a new registration', async () => {
const store = await createStore()
expect(projectedSelector(store, 'stranded')).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetMissing
})
const paused = store.updateAutomation('stranded', { enabled: false }, { expectedOwner: ORPHAN })
expect(paused.enabled).toBe(false)
expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-old', generation: 4 })
expect(projectedSelector(store, 'stranded')).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetMissing
})
})
it('leaves the record unrunnable on the host the repo now points at', async () => {
const store = await createStore()
store.updateAutomation('stranded', { enabled: false }, { expectedOwner: ORPHAN })
// Run Now against the re-added registration must still be refused; a pause is not a re-adoption.
expect(() =>
store.assertAutomationOwnerFence({
id: 'stranded',
expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-new', targetGeneration: 7 } },
operation: 'execute'
})
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }))
})
it('keeps the stale generation when the same target id came back re-registered', async () => {
const store = await createStore()
expect(projectedSelector(store, 'replaced')).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
const paused = store.updateAutomation('replaced', { enabled: false }, { expectedOwner: ORPHAN })
expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-box', generation: 4 })
expect(projectedSelector(store, 'replaced')).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
})
it('does not strip the capture and rewrite an orphan to Self', async () => {
const store = await createStore()
const paused = store.updateAutomation(
'local-project',
{ enabled: false },
{ expectedOwner: ORPHAN }
)
expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-old', generation: 4 })
expect(projectedSelector(store, 'local-project')).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetMissing
})
})
})
describe('an update that was asked to move still moves', () => {
it('re-derives from an explicitly supplied project', async () => {
const store = await createStore()
const moved = store.updateAutomation(
'healthy',
{ projectId: 'repo-readded' },
{
expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-box', targetGeneration: 9 } },
destination: { selector: { kind: 'ssh', targetId: 'ssh-new', targetGeneration: 7 } }
}
)
expect(storedTarget(moved)).toEqual({ type: 'ssh', id: 'ssh-new', generation: 7 })
})
it('leaves a fenced in-place edit on its own host', async () => {
const store = await createStore()
const renamed = store.updateAutomation(
'healthy',
{ name: 'Renamed' },
{ expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-box', targetGeneration: 9 } } }
)
expect(renamed.name).toBe('Renamed')
expect(storedTarget(renamed)).toEqual({ type: 'ssh', id: 'ssh-box', generation: 9 })
})
})
@@ -0,0 +1,258 @@
/**
* A folder workspace can pin its execution host while its repo stays local.
* The desktop authority used to project only the repo, so those records listed
* under Self while dispatch sent them to an SSH host.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation } from '../../shared/automations-types'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope'
import { AutomationService } from './service'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const REPOS: Repo[] = [
{
id: 'repo-local',
path: '/work/app/repo',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1,
projectGroupId: 'group-1'
}
] as Repo[]
const TARGETS: SshTarget[] = [
{ id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 }
] as SshTarget[]
function folderWorkspace(overrides: Partial<FolderWorkspace>): FolderWorkspace {
return {
id: 'fw-1',
projectGroupId: 'group-1',
name: 'App',
folderPath: '/work/app',
...overrides
} as FolderWorkspace
}
const GROUPS: ProjectGroup[] = [
{ id: 'group-1', name: 'Work', parentPath: '/work' }
] as ProjectGroup[]
/** One local repo and one SSH repo in the same scope: no single host owns it. */
const AMBIGUOUS_REPOS: Repo[] = [
...REPOS,
{
id: 'repo-remote',
path: '/work/app/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 2,
projectGroupId: 'group-1',
connectionId: 'ssh-1'
} as Repo
]
const AUTOMATION: Automation = {
id: 'pinned-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-local',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: 'folder:fw-1',
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0
} as Automation
async function createStore(
folderWorkspaces: FolderWorkspace[],
repos: Repo[] = REPOS,
extra: { automations?: Automation[]; projectHostSetups?: ProjectHostSetup[] } = {}
) {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos,
projectGroups: GROUPS,
folderWorkspaces,
sshTargets: TARGETS,
sshTargetGenerationCounter: 7,
projectHostSetups: extra.projectHostSetups ?? [],
automations: extra.automations ?? [AUTOMATION]
}),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-workspace-host-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
describe('folder-workspace host attribution', () => {
// The stored pin is `connectionId`; `executionHostId` is a renderer-side stamp that
// normalization drops, so main can only ever see this form.
it('files a pinned workspace under its SSH host instead of Self', async () => {
const store = await createStore([folderWorkspace({ connectionId: 'ssh-1' })], [])
expect(store.listAutomationsForScope({ selector: { kind: 'self' } }).automations).toEqual([])
const scoped = store.listAutomationsForScope({
selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 }
})
expect(scoped.automations.map((entry) => entry.id)).toEqual(['pinned-1'])
expect(scoped.items[0]?.selector).toEqual({
kind: 'ssh',
targetId: 'ssh-1',
targetGeneration: 7
})
})
it('orphans a workspace whose scope spans two hosts rather than showing a healthy Self row', async () => {
const store = await createStore([folderWorkspace({})], AMBIGUOUS_REPOS)
expect(store.listAutomationsForScope({ selector: { kind: 'self' } }).automations).toEqual([])
const orphans = store.listAutomationsForScope({ selector: { kind: 'orphan' } })
expect(orphans.items[0]?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous
})
})
it('leaves an unpinned local workspace on Self', async () => {
const store = await createStore([folderWorkspace({})])
expect(
store.listAutomationsForScope({ selector: { kind: 'self' } }).automations.map((e) => e.id)
).toEqual(['pinned-1'])
})
})
const AMBIGUOUS_REFUSAL =
'The automation workspace spans more than one host, so Orca cannot tell which one to run it on.'
/**
* Built through the store rather than seeded: load-time projection replaces any
* repo-backed setup written by hand, so a literal run context would point at a
* setup that no longer exists and be refused for that instead of the ambiguity.
*/
async function ambiguousStore() {
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
const store = await createStore([folderWorkspace({})], AMBIGUOUS_REPOS, { automations: [] })
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'go',
agentId: 'claude',
projectId: 'repo-local',
workspaceMode: 'existing',
workspaceId: 'folder:fw-1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
return { store, automation }
}
/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */
async function evaluated(
seeded: Awaited<ReturnType<typeof ambiguousStore>>,
start: () => void
): Promise<void> {
vi.setSystemTime(new Date('2026-05-13T09:01:00'))
start()
await vi.waitFor(() =>
expect(seeded.store.listAutomations()[0]?.nextRunAt).toBeGreaterThan(
seeded.automation.nextRunAt
)
)
}
/**
* The list calls this record an orphan, so the authority that owns it must refuse
* to run it. Resolution would otherwise pick one of the two hosts on its own and
* run the user's prompt somewhere they never chose — on a host they may not trust.
*/
describe('dispatch refused for an ambiguous workspace host', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-ambiguous-dispatch-'))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('refuses the desktop dispatch and records why', async () => {
const seeded = await ambiguousStore()
const send = vi.fn()
const service = new AutomationService(seeded.store, { tickMs: 60_000 })
service.setWebContents({ isDestroyed: () => false, send } as never)
await evaluated(seeded, () => service.setRendererReady())
expect(send).not.toHaveBeenCalled()
expect(seeded.store.listAutomationRuns(seeded.automation.id)[0]).toMatchObject({
status: 'skipped_unavailable',
error: AMBIGUOUS_REFUSAL
})
service.stop()
})
// Serve mode has no renderer to disable actions, so the authority is the only guard.
it('refuses the headless dispatch, which has no ambiguity check of its own', async () => {
const seeded = await ambiguousStore()
const dispatcher = vi.fn(async () => ({ workspaceId: 'folder:fw-1', terminalSessionId: 't1' }))
const service = new AutomationService(seeded.store, {
tickMs: 60_000,
headlessDispatcher: dispatcher
})
await evaluated(seeded, () => service.start())
expect(dispatcher).not.toHaveBeenCalled()
expect(seeded.store.listAutomationRuns(seeded.automation.id)[0]).toMatchObject({
status: 'skipped_unavailable',
error: AMBIGUOUS_REFUSAL
})
service.stop()
})
})
+53
View File
@@ -0,0 +1,53 @@
/**
* What the owning authority says, and writes, when it refuses to start a run.
*
* Kept together because every refusal must be one fixed sentence: skip
* coalescing folds repeats only on byte-identical text, so a reason that varied
* per occurrence would write a row each.
*/
import type { Store } from '../persistence'
import type { Automation } from '../../shared/automations-types'
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
import type { AutomationRunWriter } from './automation-run-writer'
export const NO_DISPATCH_HOST = 'No Orca window was available to launch the automation.'
/** A record the authority refuses to execute at all, with no target diagnosis of its own. */
export const NO_RUNNABLE_HOST = 'This automation has no host to run on.'
/** Every reason this occurrence cannot start, decided before a run row exists so
* the scheduler can fold repeats instead of writing one row each. */
export function describeScheduledRefusal(input: {
target: AutomationRunTargetResult
canDispatch: boolean
}): string | null {
if (!input.target.ok) {
return input.target.error
}
return input.canDispatch ? null : NO_DISPATCH_HOST
}
/**
* Records the manual attempt an execute fence refused before dispatch existed.
*
* The typed conflict answers the caller; run history is what answers the user,
* and doc:94 asks for both. Never dispatches: the reason is the one the
* scheduler would have written for the same record.
*/
export function recordRefusedAutomationRun(input: {
store: Store
runs: AutomationRunWriter
automation: Automation
allowRemoteHostScheduling: boolean
}): void {
const target = resolveAutomationRunTarget(input.store, input.automation, {
allowRemoteHostScheduling: input.allowRemoteHostScheduling
})
const run = input.runs.createRun(input.automation, Date.now(), 'manual')
input.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: input.automation.workspaceId,
error: target.ok ? NO_RUNNABLE_HOST : target.error
})
}
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest'
import {
ExternalAutomationManagerCache,
describeExternalManagerFailure
} from './external-automation-manager-cache'
import { ExternalAutomationProbeCancelledError } from './external-automation-probe-scheduler'
import type { ExternalAutomationManager } from '../../shared/automations-types'
function manager(id: string): ExternalAutomationManager {
return {
id,
provider: 'hermes',
label: id,
targetLabel: 'this computer',
target: { type: 'local' },
status: 'available',
error: null,
canManage: true,
jobs: []
}
}
const selfOwner = 'owner:desktop:self'
const sshOwner = 'owner:desktop:ssh:target-a:3'
describe('ExternalAutomationManagerCache', () => {
it('keys entries per owner and per provider', () => {
const cache = new ExternalAutomationManagerCache()
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
cache.writeFailure({ ownerKey: selfOwner, provider: 'openclaw' }, new Error('no openclaw'))
cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a'))
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.manager?.id).toBe(
'hermes:local'
)
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })?.error).toBe('no openclaw')
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.error).toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })?.manager?.id).toBe(
'hermes:ssh:target-a'
)
})
it('drops only the named host when a mutation invalidates it', () => {
const cache = new ExternalAutomationManagerCache()
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a'))
cache.write({ ownerKey: sshOwner, provider: 'openclaw' }, manager('openclaw:ssh:target-a'))
cache.invalidateOwner(sshOwner)
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })).toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'openclaw' })).toBeNull()
})
it('treats an entry past its TTL as absent', () => {
let now = 1_000
const cache = new ExternalAutomationManagerCache({ ttlMs: 100, now: () => now })
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
now += 100
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull()
now += 1
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).toBeNull()
})
it('serves a fresh entry without reloading, and reloads on refresh', async () => {
const cache = new ExternalAutomationManagerCache()
const load = vi.fn(() => Promise.resolve(manager('hermes:local')))
const key = { ownerKey: selfOwner, provider: 'hermes' }
await cache.resolve(key, load)
await cache.resolve(key, load)
expect(load).toHaveBeenCalledTimes(1)
await cache.resolve(key, load, { refresh: true })
expect(load).toHaveBeenCalledTimes(2)
})
it('records a load failure as error state rather than throwing', async () => {
const cache = new ExternalAutomationManagerCache()
const entry = await cache.resolve({ ownerKey: sshOwner, provider: 'hermes' }, () =>
Promise.reject(new Error('relay refused'))
)
expect(entry.manager).toBeNull()
expect(entry.error).toBe('relay refused')
})
it('propagates cancellation and leaves the previous entry untouched', async () => {
const cache = new ExternalAutomationManagerCache()
const key = { ownerKey: sshOwner, provider: 'hermes' }
cache.write(key, manager('hermes:ssh:target-a'))
await expect(
cache.resolve(key, () => Promise.reject(new ExternalAutomationProbeCancelledError()), {
refresh: true
})
).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
expect(cache.read(key)?.manager?.id).toBe('hermes:ssh:target-a')
})
})
describe('describeExternalManagerFailure', () => {
it('keeps only a bounded message and never the thrown payload', () => {
expect(describeExternalManagerFailure({ prompt: 'secret prompt', jobs: [1, 2] })).toBe(
'External automation manager could not be read.'
)
expect(describeExternalManagerFailure(new Error(' '))).toBe(
'External automation manager could not be read.'
)
const long = describeExternalManagerFailure(new Error('x'.repeat(400)))
expect(long).toHaveLength(301)
expect(long.endsWith('…')).toBe(true)
})
})
@@ -0,0 +1,137 @@
/**
* Per-`{owner, provider}` manager and error state.
*
* Deliberately holds no reference to Orca automation storage: a provider that is
* missing, wedged, or returning garbage is a fact about that manager only, and
* must never be able to mark the host's Orca store unavailable.
*/
import type { ExternalAutomationManager } from '../../shared/automations-types'
import { isExternalAutomationProbeCancelled } from './external-automation-probe-scheduler'
export type ExternalAutomationManagerCacheKey = {
ownerKey: string
provider: string
}
export type ExternalAutomationManagerCacheEntry = {
/** Null once a probe succeeded and found no manager configured for this scope. */
manager: ExternalAutomationManager | null
error: string | null
updatedAt: number
}
const DEFAULT_CACHE_TTL_MS = 30_000
const MAX_CACHED_ERROR_LENGTH = 300
/**
* Bounded, provider-agnostic failure text. Provider payloads can carry prompts,
* job bodies, and run output, so only an `Error.message` is ever kept and it is
* truncated; anything else is reduced to a fixed string.
*/
export function describeExternalManagerFailure(error: unknown): string {
const message = error instanceof Error ? error.message.trim() : ''
if (!message) {
return 'External automation manager could not be read.'
}
return message.length > MAX_CACHED_ERROR_LENGTH
? `${message.slice(0, MAX_CACHED_ERROR_LENGTH)}…`
: message
}
export function externalAutomationManagerCacheKey(key: ExternalAutomationManagerCacheKey): string {
// Why: ownerKey is already separator-escaped, so appending the provider stays unambiguous.
return `${key.ownerKey}|${key.provider}`
}
export class ExternalAutomationManagerCache {
private readonly entries = new Map<string, ExternalAutomationManagerCacheEntry>()
private readonly ttlMs: number
private readonly now: () => number
constructor(options?: { ttlMs?: number; now?: () => number }) {
this.ttlMs = Math.max(0, options?.ttlMs ?? DEFAULT_CACHE_TTL_MS)
this.now = options?.now ?? Date.now
}
get size(): number {
return this.entries.size
}
/** Fresh entry for this scope, or null when absent or past its TTL. */
read(key: ExternalAutomationManagerCacheKey): ExternalAutomationManagerCacheEntry | null {
const entry = this.entries.get(externalAutomationManagerCacheKey(key))
if (!entry) {
return null
}
return this.now() - entry.updatedAt <= this.ttlMs ? entry : null
}
write(
key: ExternalAutomationManagerCacheKey,
manager: ExternalAutomationManager | null
): ExternalAutomationManagerCacheEntry {
return this.store(key, { manager, error: null, updatedAt: this.now() })
}
writeFailure(
key: ExternalAutomationManagerCacheKey,
error: unknown
): ExternalAutomationManagerCacheEntry {
return this.store(key, {
manager: null,
error: describeExternalManagerFailure(error),
updatedAt: this.now()
})
}
invalidate(key: ExternalAutomationManagerCacheKey): void {
this.entries.delete(externalAutomationManagerCacheKey(key))
}
/** Drops every provider entry for one host, e.g. after a mutation on it. */
invalidateOwner(ownerKey: string): void {
for (const key of this.entries.keys()) {
if (key.startsWith(`${ownerKey}|`)) {
this.entries.delete(key)
}
}
}
clear(): void {
this.entries.clear()
}
/**
* Returns a fresh entry or loads one. A cancelled probe propagates and leaves
* any existing entry untouched, so leaving a scope never records a failure.
*/
async resolve(
key: ExternalAutomationManagerCacheKey,
load: () => Promise<ExternalAutomationManager | null>,
options?: { refresh?: boolean }
): Promise<ExternalAutomationManagerCacheEntry> {
if (!options?.refresh) {
const cached = this.read(key)
if (cached) {
return cached
}
}
try {
return this.write(key, await load())
} catch (error) {
if (isExternalAutomationProbeCancelled(error)) {
throw error
}
return this.writeFailure(key, error)
}
}
private store(
key: ExternalAutomationManagerCacheKey,
entry: ExternalAutomationManagerCacheEntry
): ExternalAutomationManagerCacheEntry {
this.entries.set(externalAutomationManagerCacheKey(key), entry)
return entry
}
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import { resolveExternalAutomationScope } from './external-automation-owner-guard'
import { AutomationOwnerConflictError } from '../../shared/automation-owner-conflict'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError
} from '../../shared/external-automation-scope'
import type { ExternalAutomationProvider } from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
function sshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'target-a',
label: 'Build box',
host: 'build.example',
port: 22,
username: 'orca',
generation: 3,
...overrides
}
}
function registry(targets: SshTarget[]): { getSshTargets: () => SshTarget[] } {
return { getSshTargets: () => targets }
}
const desktopSelf: AutomationOwnerRef = {
authority: { kind: 'desktop' },
selector: { kind: 'self' }
}
function desktopSsh(targetId: string, targetGeneration: number): AutomationOwnerRef {
return { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId, targetGeneration } }
}
describe('resolveExternalAutomationScope', () => {
it('resolves desktop + self to the local target', () => {
const scope = resolveExternalAutomationScope(
{ owner: desktopSelf, provider: 'hermes' },
registry([])
)
expect(scope.target).toEqual({ type: 'local' })
expect(scope.sshTarget).toBeNull()
expect(scope.managerId).toBe('hermes:local')
})
it('resolves a desktop SSH host whose registration generation still matches', () => {
const target = sshTarget()
const scope = resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'openclaw' },
registry([target])
)
expect(scope.target).toEqual({ type: 'ssh', connectionId: 'target-a' })
expect(scope.sshTarget).toBe(target)
expect(scope.managerId).toBe('openclaw:ssh:target-a')
})
it('excludes a runtime-owned target instead of exposing it', () => {
const target = sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } })
expect(() =>
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
registry([target])
)
).toThrow(ExternalAutomationScopeError)
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
registry([target])
)
} catch (error) {
expect((error as ExternalAutomationScopeError).code).toBe(
EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden
)
}
})
it('hides a runtime-owned target even when the captured generation is wrong', () => {
const target = sshTarget({
generation: 9,
owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' }
})
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
registry([target])
)
expect.unreachable('hidden target must not resolve')
} catch (error) {
expect((error as ExternalAutomationScopeError).code).toBe(
EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden
)
}
})
it('fails closed with the host-changed conflict on a stale generation', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 2), provider: 'hermes' },
registry([sshTarget({ generation: 3 })])
)
expect.unreachable('stale generation must not resolve')
} catch (error) {
expect(error).toBeInstanceOf(AutomationOwnerConflictError)
expect((error as AutomationOwnerConflictError).code).toBe('automation_owner_changed')
}
})
it('fails closed when the registration carries no usable generation', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 1), provider: 'hermes' },
registry([sshTarget({ generation: undefined })])
)
expect.unreachable('unstamped registration must not resolve')
} catch (error) {
expect((error as AutomationOwnerConflictError).code).toBe('automation_owner_changed')
}
})
it('reports a removed SSH host as a target-removed conflict', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('gone', 1), provider: 'hermes' },
registry([sshTarget()])
)
expect.unreachable('missing target must not resolve')
} catch (error) {
expect((error as AutomationOwnerConflictError).code).toBe('automation_target_removed')
}
})
it('rejects a runtime authority rather than tunnelling it to the desktop surface', () => {
const runtimeOwner: AutomationOwnerRef = {
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 4 },
selector: { kind: 'self' }
}
try {
resolveExternalAutomationScope({ owner: runtimeOwner, provider: 'hermes' }, registry([]))
expect.unreachable('runtime authority must not resolve')
} catch (error) {
expect((error as ExternalAutomationScopeError).code).toBe(
EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported
)
}
})
it('rejects a provider outside the allowlist', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSelf, provider: 'cron' as ExternalAutomationProvider },
registry([])
)
expect.unreachable('unknown provider must not resolve')
} catch (error) {
expect((error as ExternalAutomationScopeError).code).toBe(
EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed
)
}
})
})
@@ -0,0 +1,92 @@
/**
* The fail-closed checks every scoped external-manager call re-applies *before*
* contacting a relay or launching a provider command.
*
* Resolution is fully synchronous on purpose: a rejected scope has performed no
* probe, because there is no await between the request arriving and the throw.
*/
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError,
externalAutomationHostChangedError,
externalAutomationManagerId,
externalAutomationTargetForOwner,
externalAutomationTargetRemovedError,
isExternalAutomationProvider,
type ScopedExternalAutomationRequest
} from '../../shared/external-automation-scope'
import { ownerKey } from '../../shared/automation-owner-key'
import { sanitizeSshTargetGeneration } from '../../shared/ssh-target-generation'
import type {
ExternalAutomationProvider,
ExternalAutomationTarget
} from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store'
/** Current SSH registrations, hidden ones included so the guard can reject them itself. */
export type DesktopSshTargetRegistry = {
getSshTargets: () => SshTarget[]
}
export type ResolvedExternalAutomationScope = {
provider: ExternalAutomationProvider
target: ExternalAutomationTarget
/** Set only for SSH scopes, already proven desktop-visible and current. */
sshTarget: SshTarget | null
managerId: string
/** Cache, coalescing, and cancellation key for this `{owner, provider}`. */
ownerKey: string
}
function resolveSshScope(
targetId: string,
capturedGeneration: number,
registry: DesktopSshTargetRegistry
): SshTarget {
const target = registry.getSshTargets().find((entry) => entry.id === targetId)
if (!target) {
throw externalAutomationTargetRemovedError()
}
// Why: checked before the generation compare so a hidden target reveals nothing about its registration.
if (isRuntimeOwnedSshTarget(target)) {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden)
}
const current = sanitizeSshTargetGeneration(target.generation)
if (current === undefined || current !== capturedGeneration) {
throw externalAutomationHostChangedError()
}
return target
}
/**
* Validates provider, authority, and host incarnation for one scoped request.
* Throws `ExternalAutomationScopeError` or `AutomationOwnerConflictError`; never probes.
*/
export function resolveExternalAutomationScope(
request: ScopedExternalAutomationRequest,
registry: DesktopSshTargetRegistry
): ResolvedExternalAutomationScope {
if (!isExternalAutomationProvider(request.provider)) {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed)
}
const { owner } = request
if (owner.authority.kind !== 'desktop') {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
}
if (owner.selector.kind !== 'self' && owner.selector.kind !== 'ssh') {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
}
const sshTarget =
owner.selector.kind === 'ssh'
? resolveSshScope(owner.selector.targetId, owner.selector.targetGeneration, registry)
: null
return {
provider: request.provider,
target: externalAutomationTargetForOwner(owner),
sshTarget,
managerId: externalAutomationManagerId(owner, request.provider),
ownerKey: ownerKey(owner)
}
}
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import {
ExternalAutomationProbeCancelledError,
ExternalAutomationProbeScheduler,
isExternalAutomationProbeCancelled
} from './external-automation-probe-scheduler'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('ExternalAutomationProbeScheduler', () => {
it('never runs more probes at once than its bound allows', async () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 2 })
const gates = [deferred<string>(), deferred<string>(), deferred<string>()]
const started: string[] = []
const promises = gates.map((gate, index) =>
scheduler.schedule({
key: `probe-${index}`,
scopeKey: 'owner:desktop:self',
run: () => {
started.push(`probe-${index}`)
return gate.promise
}
})
)
expect(started).toEqual(['probe-0', 'probe-1'])
expect(scheduler.queued).toBe(1)
gates[0]?.resolve('a')
await promises[0]
expect(started).toEqual(['probe-0', 'probe-1', 'probe-2'])
gates[1]?.resolve('b')
gates[2]?.resolve('c')
await Promise.all(promises)
})
it('shares one probe between concurrent requests for the same key', async () => {
const scheduler = new ExternalAutomationProbeScheduler()
const run = vi.fn(() => Promise.resolve('manager'))
const [first, second] = await Promise.all([
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }),
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
])
expect(run).toHaveBeenCalledTimes(1)
expect(first).toBe('manager')
expect(second).toBe('manager')
})
it('parks queued probes while Orca automation work holds priority', async () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 4 })
const run = vi.fn(() => Promise.resolve('manager'))
const release = scheduler.beginPriorityWork()
const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
expect(run).not.toHaveBeenCalled()
expect(scheduler.queued).toBe(1)
release()
await probe
expect(run).toHaveBeenCalledTimes(1)
})
it('cancels queued and in-flight probes whose scope is no longer selected', async () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
const never = deferred<string>()
const queuedRun = vi.fn(() => Promise.resolve('queued'))
const inFlight = scheduler.schedule({
key: 'hermes@ssh',
scopeKey: 'owner:desktop:ssh:target-a:3',
run: () => never.promise
})
const queued = scheduler.schedule({
key: 'openclaw@ssh',
scopeKey: 'owner:desktop:ssh:target-a:3',
run: queuedRun
})
const kept = scheduler.schedule({
key: 'hermes@self',
scopeKey: 'owner:desktop:self',
run: () => Promise.resolve('local')
})
scheduler.retainScopes(['owner:desktop:self'])
await expect(inFlight).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
await expect(queued).rejects.toSatisfy(isExternalAutomationProbeCancelled)
expect(queuedRun).not.toHaveBeenCalled()
await expect(kept).resolves.toBe('local')
})
it('cancels everything on demand', async () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
const never = deferred<string>()
const probe = scheduler.schedule({
key: 'hermes@self',
scopeKey: 'owner:desktop:self',
run: () => never.promise
})
scheduler.cancelAll()
await expect(probe).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
expect(scheduler.inFlight).toBe(0)
})
})
@@ -0,0 +1,141 @@
/**
* Bounded pool for `{host, provider}` external-manager probes.
*
* Probes are always secondary to Orca automation list and mutation traffic, so
* the pool parks its queue while that work holds a priority lease. Nothing is
* scheduled speculatively: a caller schedules exactly the scopes it selected,
* which is what keeps a Local view from reaching out to every SSH target.
*/
export class ExternalAutomationProbeCancelledError extends Error {
constructor() {
super('External automation probe was cancelled.')
this.name = 'ExternalAutomationProbeCancelledError'
}
}
export function isExternalAutomationProbeCancelled(error: unknown): boolean {
return error instanceof ExternalAutomationProbeCancelledError
}
export type ExternalAutomationProbeJob<T> = {
/** `{owner, provider}` identity; a duplicate while one is in flight shares that probe. */
key: string
/** Owner key, so leaving a host's scope cancels its probes across providers. */
scopeKey: string
run: (signal: AbortSignal) => Promise<T>
}
type QueuedProbe = {
key: string
scopeKey: string
controller: AbortController
start: () => void
reject: (error: unknown) => void
}
const DEFAULT_PROBE_CONCURRENCY = 4
export class ExternalAutomationProbeScheduler {
private readonly concurrency: number
private readonly queue: QueuedProbe[] = []
private readonly active = new Map<string, { scopeKey: string; controller: AbortController }>()
private readonly shared = new Map<string, Promise<unknown>>()
private priorityHolds = 0
constructor(options?: { concurrency?: number }) {
this.concurrency = Math.max(1, options?.concurrency ?? DEFAULT_PROBE_CONCURRENCY)
}
get inFlight(): number {
return this.active.size
}
get queued(): number {
return this.queue.length
}
schedule<T>(job: ExternalAutomationProbeJob<T>): Promise<T> {
const existing = this.shared.get(job.key)
if (existing) {
return existing as Promise<T>
}
const controller = new AbortController()
const promise = new Promise<T>((resolve, reject) => {
const entry: QueuedProbe = {
key: job.key,
scopeKey: job.scopeKey,
controller,
reject,
start: () => {
this.active.set(job.key, { scopeKey: job.scopeKey, controller })
// Why: settle on abort even when a provider call cannot honour the signal.
controller.signal.addEventListener(
'abort',
() => reject(new ExternalAutomationProbeCancelledError()),
{ once: true }
)
job.run(controller.signal).then(resolve, reject)
}
}
this.queue.push(entry)
})
const settled = promise.finally(() => {
this.active.delete(job.key)
this.shared.delete(job.key)
this.pump()
})
this.shared.set(job.key, settled)
this.pump()
return settled as Promise<T>
}
/** Held for the duration of Orca list/mutation work; queued probes wait behind it. */
beginPriorityWork(): () => void {
this.priorityHolds += 1
let released = false
return () => {
if (released) {
return
}
released = true
this.priorityHolds = Math.max(0, this.priorityHolds - 1)
this.pump()
}
}
/** Drops queued and in-flight probes whose scope is no longer selected. */
retainScopes(activeScopeKeys: Iterable<string>): void {
const retained = new Set(activeScopeKeys)
this.cancelWhere((scopeKey) => !retained.has(scopeKey))
}
cancelAll(): void {
this.cancelWhere(() => true)
}
private cancelWhere(shouldCancel: (scopeKey: string) => boolean): void {
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
const entry = this.queue[index]
if (entry && shouldCancel(entry.scopeKey)) {
this.queue.splice(index, 1)
entry.reject(new ExternalAutomationProbeCancelledError())
}
}
for (const entry of this.active.values()) {
if (shouldCancel(entry.scopeKey)) {
entry.controller.abort()
}
}
}
private pump(): void {
while (this.priorityHolds === 0 && this.active.size < this.concurrency) {
const next = this.queue.shift()
if (!next) {
return
}
next.start()
}
}
}
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createScopedExternalAutomations } from './external-manager'
import { ExternalAutomationManagerCache } from './external-automation-manager-cache'
import { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import { AutomationOwnerConflictError } from '../../shared/automation-owner-conflict'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError
} from '../../shared/external-automation-scope'
import type { ExternalAutomationProvider } from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type * as Fs from 'node:fs'
const execFileMock = vi.hoisted(() =>
vi.fn((...args: unknown[]) => {
const callback = args.at(-1)
if (typeof callback === 'function') {
;(callback as (error: Error | null, stdout: string, stderr: string) => void)(null, '', '')
}
return { kill: vi.fn() }
})
)
const existsSyncMock = vi.hoisted(() => vi.fn(() => false))
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return { ...actual, existsSync: existsSyncMock }
})
vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: vi.fn() }))
function sshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'target-a',
label: 'Build box',
host: 'build.example',
port: 22,
username: 'orca',
generation: 3,
...overrides
}
}
const desktopSelf: AutomationOwnerRef = {
authority: { kind: 'desktop' },
selector: { kind: 'self' }
}
function desktopSsh(targetId: string, targetGeneration: number): AutomationOwnerRef {
return { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId, targetGeneration } }
}
/** Stands in for the Orca automation store: readable, but never markable unavailable from here. */
function orcaStoreHealth() {
return { read: vi.fn<() => void>(), markUnavailable: vi.fn<() => void>() }
}
function buildEngine(targets: SshTarget[]) {
return createScopedExternalAutomations({
registry: { getSshTargets: () => targets },
scheduler: new ExternalAutomationProbeScheduler(),
cache: new ExternalAutomationManagerCache({ ttlMs: 0 })
})
}
function relay(response: unknown): {
isDisposed: () => boolean
request: ReturnType<typeof vi.fn>
} {
return { isDisposed: () => false, request: vi.fn().mockResolvedValue(response) }
}
beforeEach(() => {
execFileMock.mockReset()
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1)
if (typeof callback === 'function') {
;(callback as (error: Error | null, stdout: string, stderr: string) => void)(null, '', '')
}
return { kill: vi.fn() }
})
existsSyncMock.mockReturnValue(false)
vi.mocked(getActiveMultiplexer).mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
describe('scoped external automations', () => {
// The probe's own timeout, not the caller's: a provider binary that never
// returns must still resolve the entry, or the page waits forever on one host.
it('settles when a local command lookup hangs', async () => {
vi.useFakeTimers()
execFileMock.mockImplementation(() => ({ kill: vi.fn() }))
const promise = buildEngine([]).listManager({ owner: desktopSelf, provider: 'hermes' })
let settled = false
void promise.finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(5_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(promise).resolves.toMatchObject({ manager: null })
})
it('probes only local managers when Local is selected', async () => {
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
const entry = await engine.listManager({ owner: desktopSelf, provider: 'hermes' })
expect(entry.manager?.id).toBe('hermes:local')
expect(getActiveMultiplexer).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock.mock.calls[0]?.[1]).toEqual(['hermes'])
})
it('does not reach out to any SSH target from a Local view', async () => {
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
await engine.listManager({ owner: desktopSelf, provider: 'hermes' })
await engine.listManager({ owner: desktopSelf, provider: 'openclaw' })
expect(getActiveMultiplexer).not.toHaveBeenCalled()
})
it('probes only the selected SSH host', async () => {
vi.mocked(getActiveMultiplexer).mockReturnValue(
relay({ jobs: [], hermesAvailable: true }) as unknown as ReturnType<
typeof getActiveMultiplexer
>
)
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
const entry = await engine.listManager({
owner: desktopSsh('target-a', 3),
provider: 'hermes'
})
expect(entry.manager?.id).toBe('hermes:ssh:target-a')
expect(getActiveMultiplexer).toHaveBeenCalledTimes(1)
expect(getActiveMultiplexer).toHaveBeenCalledWith('target-a')
})
it('fails closed on a stale generation without probing', async () => {
const engine = buildEngine([sshTarget({ generation: 4 })])
await expect(
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' })
).rejects.toBeInstanceOf(AutomationOwnerConflictError)
expect(getActiveMultiplexer).not.toHaveBeenCalled()
expect(execFileMock).not.toHaveBeenCalled()
})
it('excludes a runtime-owned target without probing it', async () => {
const engine = buildEngine([
sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } })
])
await expect(
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' })
).rejects.toBeInstanceOf(ExternalAutomationScopeError)
expect(getActiveMultiplexer).not.toHaveBeenCalled()
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects a runtime authority before any probe', async () => {
const engine = buildEngine([])
await expect(
engine.listManager({
owner: {
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 },
selector: { kind: 'self' }
},
provider: 'hermes'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
expect(execFileMock).not.toHaveBeenCalled()
expect(getActiveMultiplexer).not.toHaveBeenCalled()
})
it('rejects an unknown provider before launching any command', async () => {
const engine = buildEngine([sshTarget()])
const unknown = 'cron' as ExternalAutomationProvider
await expect(
engine.runAction({
owner: desktopSelf,
provider: unknown,
jobId: 'job-1',
action: 'delete'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed)
expect(execFileMock).not.toHaveBeenCalled()
expect(getActiveMultiplexer).not.toHaveBeenCalled()
})
it('fails a scoped mutation closed when the host changed under the dialog', async () => {
const engine = buildEngine([sshTarget({ generation: 5 })])
await expect(
engine.update({
owner: desktopSsh('target-a', 3),
provider: 'hermes',
jobId: 'job-1',
name: 'Nightly',
prompt: 'do the thing',
schedule: '0 2 * * *',
workdir: null
})
).rejects.toBeInstanceOf(AutomationOwnerConflictError)
expect(execFileMock).not.toHaveBeenCalled()
expect(getActiveMultiplexer).not.toHaveBeenCalled()
})
it('confines a manager failure to its own scope and never to Orca store health', async () => {
const health = orcaStoreHealth()
vi.mocked(getActiveMultiplexer).mockReturnValue(
undefined as unknown as ReturnType<typeof getActiveMultiplexer>
)
const engine = createScopedExternalAutomations({
registry: {
getSshTargets: () => {
health.read()
return [sshTarget()]
}
},
scheduler: new ExternalAutomationProbeScheduler(),
cache: new ExternalAutomationManagerCache({ ttlMs: 0 })
})
const failed = await engine.listManager({
owner: desktopSsh('target-a', 3),
provider: 'hermes'
})
const healthy = await engine.listManager({ owner: desktopSelf, provider: 'hermes' })
expect(failed.manager?.status).toBe('unavailable')
expect(failed.manager?.error).toBe('SSH target is not connected.')
expect(healthy.manager?.status).toBe('available')
expect(health.markUnavailable).not.toHaveBeenCalled()
})
it('routes scoped runs through the selected host without accepting a target from the caller', async () => {
const requestMock = vi.fn().mockResolvedValue({ total: 2, runs: [] })
vi.mocked(getActiveMultiplexer).mockReturnValue({
isDisposed: () => false,
request: requestMock
} as unknown as ReturnType<typeof getActiveMultiplexer>)
const engine = buildEngine([sshTarget()])
const page = await engine.listRuns({
owner: desktopSsh('target-a', 3),
provider: 'hermes',
jobId: 'job-1',
page: 1,
pageSize: 10
})
expect(page.target).toEqual({ type: 'ssh', connectionId: 'target-a' })
expect(page.managerId).toBe('hermes:ssh:target-a')
expect(page.total).toBe(2)
})
})
+89 -22
View File
@@ -1,14 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createExternalAutomation,
listExternalAutomationManagers,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../shared/external-automation-scope'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import type { Store } from '../persistence'
import type {
ExternalAutomationAction,
ExternalAutomationProvider
} from '../../shared/automations-types'
import type * as Fs from 'node:fs'
const execFileMock = vi.hoisted(() =>
@@ -56,26 +59,6 @@ afterEach(() => {
vi.useRealTimers()
})
describe('listExternalAutomationManagers', () => {
it('settles when a local command lookup hangs', async () => {
vi.useFakeTimers()
execFileMock.mockImplementation(() => ({ kill: vi.fn() }))
const promise = listExternalAutomationManagers({
getSshTargets: () => []
} as unknown as Store)
let settled = false
void promise.finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(5_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(promise).resolves.toEqual([])
})
})
describe('mapHermesJobs', () => {
it('normalizes Hermes cron jobs into external automation rows', () => {
const jobs = mapHermesJobs('hermes:local', [
@@ -322,6 +305,48 @@ describe('runExternalAutomationAction', () => {
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects an action name that is not in the provider table', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
action: 'purge' as ExternalAutomationAction
})
).rejects.toThrow('Unsupported external automation action.')
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects an action inherited from the prototype chain', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
action: 'toString' as unknown as ExternalAutomationAction
})
).rejects.toThrow('Unsupported external automation action.')
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects a provider that has no action table', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'shellcat' as ExternalAutomationProvider,
target: { type: 'local' },
jobId: 'job-1',
action: 'run'
})
).rejects.toThrow('Unsupported external automation action.')
expect(execFileMock).not.toHaveBeenCalled()
})
it('maps OpenClaw lifecycle actions through its cron CLI names', async () => {
await runExternalAutomationAction({
managerId: 'openclaw:local',
@@ -395,6 +420,48 @@ describe('listExternalAutomationRuns', () => {
pageSize: 10
})
})
it('reports a structured -32601 as an unsupported runs endpoint', async () => {
// Shaped like the multiplexer's rejection: a non-enumerable `code` that no
// amount of message parsing on the far side of IPC could recover.
const rejection = new Error('Method not found: externalAutomations.runs')
Object.defineProperty(rejection, 'code', { value: -32601 })
vi.mocked(getActiveMultiplexer).mockReturnValue({
isDisposed: () => false,
request: vi.fn().mockRejectedValue(rejection)
} as unknown as ReturnType<typeof getActiveMultiplexer>)
await expect(
listExternalAutomationRuns({
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
target: { type: 'ssh', connectionId: 'ssh-1' },
jobId: 'job-1',
page: 1,
pageSize: 10
})
).rejects.toMatchObject({ code: EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported })
})
it('propagates a relay failure that only reads like a missing method', async () => {
// No code, so the peer never declined the method — this is a broken call, and
// reporting it as "no runs endpoint" would show a truncated list as complete.
vi.mocked(getActiveMultiplexer).mockReturnValue({
isDisposed: () => false,
request: vi.fn().mockRejectedValue(new Error('relay crashed: method not found in registry'))
} as unknown as ReturnType<typeof getActiveMultiplexer>)
await expect(
listExternalAutomationRuns({
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
target: { type: 'ssh', connectionId: 'ssh-1' },
jobId: 'job-1',
page: 1,
pageSize: 10
})
).rejects.toThrow('relay crashed: method not found in registry')
})
})
describe('mapOpenClawJobs', () => {
+280 -251
View File
@@ -15,10 +15,31 @@ import type {
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput
} from '../../shared/automations-types'
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError,
type ScopedExternalAutomationRequest,
type ScopedExternalManagerActionRequest,
type ScopedExternalManagerCreateRequest,
type ScopedExternalManagerListRequest,
type ScopedExternalManagerMutationFields,
type ScopedExternalManagerRunsRequest,
type ScopedExternalManagerUpdateRequest
} from '../../shared/external-automation-scope'
import type { SshTarget } from '../../shared/ssh-types'
import type { Store } from '../persistence'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store'
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import {
resolveExternalAutomationScope,
type DesktopSshTargetRegistry,
type ResolvedExternalAutomationScope
} from './external-automation-owner-guard'
import {
externalAutomationManagerCacheKey,
type ExternalAutomationManagerCache,
type ExternalAutomationManagerCacheEntry
} from './external-automation-manager-cache'
import type { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import {
clearHermesCronOutputRunCountCache,
@@ -32,22 +53,20 @@ const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000
const PROVIDER_LABELS: Record<ExternalAutomationProvider, string> = {
hermes: 'Hermes',
openclaw: 'OpenClaw'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
await runLocalCommandLookup(finder, [command])
return true
} catch {
return false
}
}
function runLocalCommandLookup(command: string, args: string[]): Promise<void> {
function runLocalProviderCommand(
command: string,
args: string[],
options: { timeoutMs: number; timeoutMessage: string; execTimeout?: boolean }
): Promise<void> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
@@ -65,57 +84,16 @@ function runLocalCommandLookup(command: string, args: string[]): Promise<void> {
resolve()
}
// Why: these probes run while loading Automations; a wedged PATH shim must
// not keep the list IPC pending forever.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`))
}, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS)
try {
child = execFile(command, args, { encoding: 'utf-8' }, (error) => {
finish(error ?? null)
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
reject(error)
return
}
resolve()
}
// Why: local automation mutations back UI actions; a wedged CLI must not
// keep create/edit/run/delete pending after Node signals a timeout.
const timeout = setTimeout(() => {
child?.kill()
finish(
new Error(
`Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`
)
)
}, LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS)
finish(new Error(options.timeoutMessage))
}, options.timeoutMs)
try {
child = execFile(
command,
args,
{ encoding: 'utf-8', timeout: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS },
{ encoding: 'utf-8', ...(options.execTimeout ? { timeout: options.timeoutMs } : {}) },
(error) => {
finish(error ?? null)
}
@@ -126,6 +104,31 @@ function runLocalAutomationCommand(command: string, args: string[]): Promise<voi
})
}
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
// Why: these probes run while loading Automations; a wedged PATH shim must
// not keep the list IPC pending forever.
await runLocalProviderCommand(finder, [command], {
timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS,
timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`
})
return true
} catch {
return false
}
}
// Why: local automation mutations back UI actions; a wedged CLI must not keep
// create/edit/run/delete pending after Node signals a timeout.
function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
return runLocalProviderCommand(command, args, {
timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS,
timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`,
execTimeout: true
})
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []
@@ -165,83 +168,84 @@ async function readLocalOpenClawJobs(): Promise<unknown[]> {
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
}
async function listLocalHermesManager(): Promise<ExternalAutomationManager | null> {
const [hermesAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('hermes'),
readLocalHermesJobs()
function mapProviderJobs(
provider: ExternalAutomationProvider,
managerId: string,
jobs: unknown[]
): ExternalAutomationManager['jobs'] {
return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs)
}
async function listLocalManager(
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager | null> {
const [availableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath(provider),
provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs()
])
const hermesAvailable =
hermesAvailableResult.status === 'fulfilled' && hermesAvailableResult.value
const available = availableResult.status === 'fulfilled' && availableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!hermesAvailable && jobs.length === 0 && !readError) {
if (!available && jobs.length === 0 && !readError) {
return null
}
const managerId = 'hermes:local'
const managerId = `${provider}:local`
const providerLabel = PROVIDER_LABELS[provider]
return {
id: managerId,
provider: 'hermes',
label: 'Hermes on this computer',
provider,
label: `${providerLabel} on this computer`,
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(hermesAvailable ? null : 'Hermes jobs were found, but the hermes CLI is not on PATH.'),
canManage: !readError && hermesAvailable,
jobs: mapHermesJobs(managerId, jobs)
(available
? null
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
canManage: !readError && available,
jobs: mapProviderJobs(provider, managerId, jobs)
}
}
async function listLocalOpenClawManager(): Promise<ExternalAutomationManager | null> {
const [openClawAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('openclaw'),
readLocalOpenClawJobs()
])
const openClawAvailable =
openClawAvailableResult.status === 'fulfilled' && openClawAvailableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!openClawAvailable && jobs.length === 0 && !readError) {
return null
}
const managerId = 'openclaw:local'
return {
id: managerId,
provider: 'openclaw',
label: 'OpenClaw on this computer',
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(openClawAvailable ? null : 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'),
canManage: !readError && openClawAvailable,
jobs: mapOpenClawJobs(managerId, jobs)
function requireMultiplexer(
connectionId: string
): NonNullable<ReturnType<typeof getActiveMultiplexer>> {
const mux = getActiveMultiplexer(connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${connectionId}" is not connected.`)
}
return mux
}
/**
* True only for a structured JSON-RPC `-32601`, never for error prose.
*
* A `-32601` object means the peer parsed the request and declined the method,
* so it is positive evidence the connection is healthy. Matching the words
* "method not found" instead would let a genuinely broken relay be reported as
* a missing capability, hiding a dead connection behind a capability notice.
*/
function isRelayMethodNotFoundError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
)
}
function remoteRelayErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('-32601') || /method not found/i.test(message)) {
if (isRelayMethodNotFoundError(error)) {
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
}
return message
}
async function listRemoteHermesManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'hermes')
}
async function listRemoteOpenClawManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'openclaw')
return error instanceof Error ? error.message : String(error)
}
async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager> {
const providerLabel = provider === 'hermes' ? 'Hermes' : 'OpenClaw'
const providerLabel = PROVIDER_LABELS[provider]
const managerProviderId = `${provider}:ssh:${target.id}`
const mux = getActiveMultiplexer(target.id)
if (!mux || mux.isDisposed()) {
@@ -277,10 +281,7 @@ async function listRemoteManager(
error:
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
canManage: !readError && commandAvailable,
jobs:
provider === 'hermes'
? mapHermesJobs(managerProviderId, result.jobs ?? [])
: mapOpenClawJobs(managerProviderId, result.jobs ?? [])
jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? [])
}
} catch (error) {
return {
@@ -297,26 +298,30 @@ async function listRemoteManager(
}
}
export async function listExternalAutomationManagers(
store: Store
): Promise<ExternalAutomationManager[]> {
const [localHermes, localOpenClaw, remote] = await Promise.all([
listLocalHermesManager(),
listLocalOpenClawManager(),
Promise.all(
store
.getSshTargets()
// Why: runtime-owned hidden targets are excluded from SSH/run-target
// surfaces; don't probe them for external automations either.
.filter((target) => !isRuntimeOwnedSshTarget(target))
.flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)])
)
])
return [
...(localHermes ? [localHermes] : []),
...(localOpenClaw ? [localOpenClaw] : []),
...remote
]
/**
* Runs the relay's paged runs call, translating "no such method" into a code the
* renderer can act on.
*
* The structured code exists only here: Electron IPC preserves nothing but the
* message, so a renderer that re-derived this from the host's prose would be
* guessing. Translating at the boundary that can still see the code is what lets
* the caller fall back to job-carried runs without matching free text.
*/
async function requestRemoteExternalRuns(
connectionId: string,
params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number }
): Promise<{ total?: number; runs?: unknown[] }> {
try {
return (await requireMultiplexer(connectionId).request('externalAutomations.runs', params)) as {
total?: number
runs?: unknown[]
}
} catch (error) {
if (isRelayMethodNotFoundError(error)) {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported)
}
throw error
}
}
export async function listExternalAutomationRuns(
@@ -329,78 +334,55 @@ export async function listExternalAutomationRuns(
const pageSize = Number.isFinite(input.pageSize)
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
: 25
if (input.provider !== 'hermes') {
return {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
total: 0,
runs: []
}
}
if (input.target.type === 'local') {
const result = await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
return {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
total: result.total,
runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs }])[0]?.runs ?? []
}
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
const result = (await mux.request('externalAutomations.runs', {
provider: input.provider,
jobId: input.jobId,
page,
pageSize
})) as { total?: number; runs?: unknown[] }
return {
const identity = {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
pageSize
}
if (input.provider !== 'hermes') {
return { ...identity, total: 0, runs: [] }
}
const result =
input.target.type === 'local'
? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
: await requestRemoteExternalRuns(input.target.connectionId, {
provider: input.provider,
jobId: input.jobId,
page,
pageSize
})
return {
...identity,
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
runs:
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
}
}
function hermesCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'pause'
case 'resume':
return 'resume'
case 'run':
return 'run'
case 'delete':
return 'remove'
}
const PROVIDER_ACTION_COMMANDS: Record<
ExternalAutomationProvider,
Record<ExternalAutomationAction, string>
> = {
hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' },
openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }
}
function openClawCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'disable'
case 'resume':
return 'enable'
case 'run':
return 'run'
case 'delete':
return 'rm'
/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */
function providerActionCommand(
provider: ExternalAutomationProvider,
action: ExternalAutomationAction
): string {
const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider)
? PROVIDER_ACTION_COMMANDS[provider]
: null
const command = commands && Object.hasOwn(commands, action) ? commands[action] : null
if (typeof command !== 'string') {
throw new Error('Unsupported external automation action.')
}
return command
}
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
@@ -430,48 +412,18 @@ function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput):
}
}
function hermesCronCreateArgs(input: {
name: string
prompt: string
schedule: string
workdir: string | null
}): string[] {
const args = [
'cron',
'create',
input.schedule,
input.prompt,
'--name',
input.name,
'--deliver',
'local'
]
if (input.workdir) {
args.push('--workdir', input.workdir)
}
return args
}
function hermesCronEditArgs(
jobId: string,
input: {
name: string
prompt: string
schedule: string
workdir: string | null
}
/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */
function hermesCronMutationArgs(
jobId: string | null,
input: { name: string; prompt: string; schedule: string; workdir: string | null }
): string[] {
const args = [
'cron',
'edit',
jobId,
'--schedule',
input.schedule,
'--prompt',
input.prompt,
'--name',
input.name
]
const args = jobId
? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt]
: ['cron', 'create', input.schedule, input.prompt]
args.push('--name', input.name)
if (!jobId) {
args.push('--deliver', 'local')
}
if (input.workdir) {
args.push('--workdir', input.workdir)
}
@@ -483,15 +435,11 @@ export async function createExternalAutomation(
): Promise<void> {
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronCreateArgs(normalized))
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized))
clearHermesCronOutputRunCountCache()
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.create', {
await requireMultiplexer(input.target.connectionId).request('externalAutomations.create', {
provider: input.provider,
...normalized
})
@@ -505,15 +453,11 @@ export async function updateExternalAutomation(
}
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronEditArgs(input.jobId, normalized))
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized))
clearHermesCronOutputRunCountCache(input.jobId)
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.update', {
await requireMultiplexer(input.target.connectionId).request('externalAutomations.update', {
provider: input.provider,
jobId: input.jobId,
...normalized
@@ -526,10 +470,7 @@ export async function runExternalAutomationAction(
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const command =
input.provider === 'hermes'
? hermesCommandForAction(input.action)
: openClawCommandForAction(input.action)
const command = providerActionCommand(input.provider, input.action)
if (input.target.type === 'local') {
await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId])
if (input.provider === 'hermes') {
@@ -537,13 +478,101 @@ export async function runExternalAutomationAction(
}
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.act', {
await requireMultiplexer(input.target.connectionId).request('externalAutomations.act', {
provider: input.provider,
action: input.action,
jobId: input.jobId
})
}
export type ScopedExternalAutomationDeps = {
registry: DesktopSshTargetRegistry
scheduler: ExternalAutomationProbeScheduler
cache: ExternalAutomationManagerCache
}
export type ScopedExternalAutomations = {
listManager: (
request: ScopedExternalManagerListRequest
) => Promise<ExternalAutomationManagerCacheEntry>
listRuns: (request: ScopedExternalManagerRunsRequest) => Promise<ExternalAutomationRunsPage>
create: (request: ScopedExternalManagerCreateRequest) => Promise<void>
update: (request: ScopedExternalManagerUpdateRequest) => Promise<void>
runAction: (request: ScopedExternalManagerActionRequest) => Promise<void>
}
/**
* Scoped entry points: one captured desktop owner in, one host's managers out.
* Every call re-resolves its scope before any probe, so a host that changed under
* a held-open dialog fails closed instead of acting on the new incarnation.
*/
export function createScopedExternalAutomations(
deps: ScopedExternalAutomationDeps
): ScopedExternalAutomations {
const resolve = (request: ScopedExternalAutomationRequest): ResolvedExternalAutomationScope =>
resolveExternalAutomationScope(request, deps.registry)
const mutationInput = (
scope: ResolvedExternalAutomationScope,
fields: ScopedExternalManagerMutationFields
): ExternalAutomationCreateInput => ({
managerId: scope.managerId,
provider: scope.provider,
target: scope.target,
name: fields.name,
prompt: fields.prompt,
schedule: fields.schedule,
workdir: fields.workdir
})
return {
async listManager(request) {
const scope = resolve(request)
const key = { ownerKey: scope.ownerKey, provider: scope.provider }
return await deps.cache.resolve(
key,
() =>
deps.scheduler.schedule({
key: externalAutomationManagerCacheKey(key),
scopeKey: scope.ownerKey,
run: () =>
scope.sshTarget
? listRemoteManager(scope.sshTarget, scope.provider)
: listLocalManager(scope.provider)
}),
{ refresh: request.refresh }
)
},
async listRuns(request) {
const scope = resolve(request)
return await listExternalAutomationRuns({
managerId: scope.managerId,
provider: scope.provider,
target: scope.target,
jobId: request.jobId,
page: request.page,
pageSize: request.pageSize
})
},
async create(request) {
const scope = resolve(request)
await createExternalAutomation(mutationInput(scope, request))
deps.cache.invalidateOwner(scope.ownerKey)
},
async update(request) {
const scope = resolve(request)
await updateExternalAutomation({ ...mutationInput(scope, request), jobId: request.jobId })
deps.cache.invalidateOwner(scope.ownerKey)
},
async runAction(request) {
const scope = resolve(request)
await runExternalAutomationAction({
managerId: scope.managerId,
provider: scope.provider,
target: scope.target,
jobId: request.jobId,
action: request.action
})
deps.cache.invalidateOwner(scope.ownerKey)
}
}
}
@@ -0,0 +1,94 @@
import type {
Automation,
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun
} from '../../shared/automations-types'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../shared/automation-precheck'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import type { AutomationRunTargetResult } from './run-target-resolution'
import type { AutomationRunWriter } from './automation-run-writer'
export type HeadlessAutomationDispatchContext = {
automation: Automation
run: AutomationRun
target: Extract<AutomationRunTargetResult, { ok: true }>
dispatcher: HeadlessAutomationDispatcher
runs: AutomationRunWriter
runPrecheck: () => Promise<AutomationPrecheckResult | null>
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
watchRun: (run: AutomationRun) => void
}
function describeDispatchError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export async function runHeadlessAutomationDispatch(
ctx: HeadlessAutomationDispatchContext
): Promise<AutomationRun> {
const { automation, run, target, runs } = ctx
const precheckResult =
run.trigger === 'scheduled' && automation.precheck ? await ctx.runPrecheck() : null
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
return runs.updateRun({
runId: run.id,
status: 'skipped_precheck',
workspaceId: automation.workspaceId,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
}
try {
const launch = await ctx.dispatcher({ automation, run, target })
const launchRunTarget = {
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = runs.updateRun({
runId: run.id,
status: 'dispatched',
...launchRunTarget,
error: null
})
if (!launch.completion) {
// Why: a dispatcher that reports no completion promise would otherwise
// leave the run at 'dispatched' for the process lifetime.
ctx.watchRun(updated)
return updated
}
void launch.completion
.then((completion) =>
ctx.markDispatchResult({
runId: run.id,
status: completion.status,
...launchRunTarget,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
})
)
.catch((error) =>
ctx.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
...launchRunTarget,
error: describeDispatchError(error)
})
)
return updated
} catch (error) {
return runs.updateRun({
runId: run.id,
status: 'dispatch_failed',
workspaceId: automation.workspaceId,
error: describeDispatchError(error)
})
}
}
@@ -0,0 +1,154 @@
/**
* doc:94 pairs "return the typed `target_removed` conflict" with "record a
* skipped-run reason". The scheduler already did both; a manual attempt used to
* throw and write nothing, so the same automation left a row when a schedule
* refused it and no trace at all when the user asked by hand.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict'
import { AutomationService } from './service'
const testState = { dir: '' }
const ipcHandlers = new Map<string, (event: unknown, args: unknown) => unknown>()
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => {
ipcHandlers.set(channel, handler)
}
},
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'a1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 7,
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const REPOS: Repo[] = [
{
id: 'repo-ssh',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 2,
connectionId: 'ssh-1'
}
] as Repo[]
const TARGETS: SshTarget[] = [
{ id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 }
] as SshTarget[]
const AUTOMATIONS: Automation[] = [
automation({ id: 'live-1' }),
automation({ id: 'orphan-1', executionTargetId: 'ssh-gone', executionTargetGeneration: 3 })
]
async function createStore() {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos: REPOS,
sshTargets: TARGETS,
sshTargetGenerationCounter: 7,
automations: AUTOMATIONS
}),
'utf-8'
)
vi.resetModules()
ipcHandlers.clear()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
const store = new Store()
const service = new AutomationService(store, { tickMs: 60_000 })
const { registerAutomationHandlers } = await import('../ipc/automations')
registerAutomationHandlers(store, service)
return { store, service }
}
/** Async like the real channel: `ipcMain.handle` turns a sync throw into a rejection too. */
const runNow = async (id: string, expectedOwner?: unknown): Promise<AutomationRun> =>
(await ipcHandlers.get('automations:runNow')!(null, { id, expectedOwner })) as AutomationRun
describe('manual run refused before dispatch', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-refused-manual-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('answers the caller with the conflict and still leaves the user a run record', async () => {
const { store, service } = await createStore()
await expect(runNow('orphan-1')).rejects.toMatchObject({
code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved
})
// Same sentence the scheduler writes for this record, so run history reads
// the same whether the refusal came from a schedule or from the user.
expect(store.listAutomationRuns('orphan-1')).toMatchObject([
{
status: 'skipped_unavailable',
trigger: 'manual',
error: 'The automation host is no longer registered, so this automation has nowhere to run.'
}
])
service.stop()
})
/**
* `owner_changed` says the CALLER is stale, not that the record is unrunnable.
* A client that re-reads the owner and retries would otherwise fill run history
* with rows for runs the user never saw refused.
*/
it('writes nothing when the refusal is about a stale caller, not a lost host', async () => {
const { store, service } = await createStore()
const stale = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } }
await expect(runNow('live-1', stale)).rejects.toMatchObject({
code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged
})
expect(store.listAutomationRuns('live-1')).toEqual([])
service.stop()
})
})
@@ -0,0 +1,42 @@
/**
* The execute fence, plus the run-history row a refused manual attempt leaves.
*
* doc:94 pairs "return the typed `target_removed` conflict" with "record a
* skipped-run reason", and the scheduled path already does both. Without this
* the same automation produces a row when a schedule refuses it and nothing at
* all when the user asks by hand, so run history contradicts itself.
*
* Only `target_removed` records: it is the verdict that the record has no host
* at all. `owner_changed` and `fencing_required` say the CALLER is stale, and a
* client re-reading and retrying must not litter history on the way.
*/
import type { AutomationRun } from '../../shared/automations-types'
import {
AUTOMATION_OWNER_CONFLICT_CODES,
AutomationOwnerConflictError
} from '../../shared/automation-owner-conflict'
type RefusableAutomationService = {
runNow: (automationId: string) => Promise<AutomationRun>
recordRefusedRun: (automationId: string) => void
}
export async function runAutomationNowFenced(input: {
fence: () => void
service: RefusableAutomationService
automationId: string
}): Promise<AutomationRun> {
try {
input.fence()
} catch (error) {
if (
error instanceof AutomationOwnerConflictError &&
error.code === AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved
) {
input.service.recordRefusedRun(input.automationId)
}
throw error
}
return await input.service.runNow(input.automationId)
}
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import {
isFinalAutomationRunStatus,
type Automation,
type AutomationRun
} from '../../shared/automations-types'
import { AutomationService } from './service'
import type {
AutomationRunCompletionObservation,
AutomationRunTerminalObserver
} from './run-completion-watcher'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
ipcMain: { handle: () => {} },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
async function createStore() {
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
type TestStore = Awaited<ReturnType<typeof createStore>>
const makeRepo = (): Repo => ({
id: 'r1',
path: '/repo',
displayName: 'test',
badgeColor: '#fff',
addedAt: 1
})
const LAUNCH_TARGET = {
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
terminalPaneKey: 'tab-1:11111111-2222-4333-8444-555555555555',
terminalPtyId: 'pty-1'
}
/** Well past the reconciler's post-ready settle window. */
const AFTER_SETTLE_MS = 10 * 60 * 1000
function createAutomation(store: TestStore): Automation {
store.addRepo(makeRepo())
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
})
}
/** A run left behind by a quit while its agent was mid-task. */
function retainDispatchedRun(store: TestStore, automation: Automation): AutomationRun {
const run = store.createAutomationRun(automation, Date.now() - 60_000, 'manual')
return store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
...LAUNCH_TARGET,
error: null
})
}
function readRun(store: TestStore, automationId: string, runId: string): AutomationRun {
const run = store.listAutomationRuns(automationId).find((entry) => entry.id === runId)
if (!run) {
throw new Error('run missing')
}
return run
}
/** Stands in for the window graph: null until the pane it describes is mounted. */
function createPaneSurface(): {
observer: AutomationRunTerminalObserver
mountPane: () => void
settleObservation: (value: AutomationRunCompletionObservation) => void
observedHandles: string[]
} {
const state = { mounted: false }
const observedHandles: string[] = []
let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null
return {
observer: {
resolveRunTerminal: (run) => (state.mounted && run.terminalPaneKey ? 'handle-1' : null),
observeCompletion: (handle) => {
observedHandles.push(handle)
return new Promise<AutomationRunCompletionObservation>((resolve) => {
resolveObservation = resolve
})
}
},
mountPane: () => {
state.mounted = true
},
settleObservation: (value) => resolveObservation?.(value),
observedHandles
}
}
describe('reconciling retained runs against a graph that has not published yet', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-retained-run-'))
vi.useFakeTimers()
// Away from the automation's 09:00 UTC occurrence so timer advances stay inert.
vi.setSystemTime(new Date('2026-06-01T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('does not fail a desktop-startup retained run while the window graph is empty', async () => {
// Desktop arms automations inside openMainWindow, before the renderer has
// loaded — every pane lookup returns null and none of it is evidence.
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
const surface = createPaneSurface()
const service = new AutomationService(store, { terminalObserver: surface.observer })
service.setWebContents({ isDestroyed: () => false } as never)
service.start()
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
expect(readRun(store, automation.id, retained.id).error).toBeNull()
service.stop()
})
it('re-attaches to the run once the restored pane mounts seconds later', async () => {
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
const surface = createPaneSurface()
const service = new AutomationService(store, { terminalObserver: surface.observer })
service.start()
service.setRendererReady()
await vi.advanceTimersByTimeAsync(5_000)
expect(surface.observedHandles).toEqual([])
surface.mountPane()
await vi.advanceTimersByTimeAsync(5_000)
expect(surface.observedHandles).toEqual(['handle-1'])
surface.settleObservation({ status: 'completed', error: null })
await vi.advanceTimersByTimeAsync(0)
expect(readRun(store, automation.id, retained.id).status).toBe('completed')
service.stop()
})
it('persists the terminal status exactly once when the restored pane finishes the run', async () => {
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
const surface = createPaneSurface()
const service = new AutomationService(store, { terminalObserver: surface.observer })
const transitions: string[] = []
const persist = store.updateAutomationRun.bind(store)
vi.spyOn(store, 'updateAutomationRun').mockImplementation((update) => {
const before = store.listAutomationRuns(automation.id).find((r) => r.id === update.runId)
if (before && !isFinalAutomationRunStatus(before.status)) {
if (isFinalAutomationRunStatus(update.status)) {
transitions.push(update.status)
}
}
return persist(update)
})
service.start()
service.setRendererReady()
await vi.advanceTimersByTimeAsync(5_000)
// The restored pane comes back and its renderer reports the real outcome.
surface.mountPane()
await service.markDispatchResult({
runId: retained.id,
status: 'completed',
...LAUNCH_TARGET,
error: null
})
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
expect(transitions).toEqual(['completed'])
expect(readRun(store, automation.id, retained.id).status).toBe('completed')
expect(readRun(store, automation.id, retained.id).error).toBeNull()
service.stop()
})
it('does not fail a serve-startup retained run whose host pane has not reattached', async () => {
// Serve publishes an explicitly empty graph and adopts SSH panes only after
// the connection comes up, so its surface answers null at start() too.
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
const surface = createPaneSurface()
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: surface.observer
})
service.start()
await vi.advanceTimersByTimeAsync(30_000)
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
surface.mountPane()
await vi.advanceTimersByTimeAsync(5_000)
expect(surface.observedHandles).toEqual(['handle-1'])
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
service.stop()
})
it('still closes out a run the ready surface cannot find after the settle window', async () => {
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
const surface = createPaneSurface()
const service = new AutomationService(store, { terminalObserver: surface.observer })
service.start()
service.setRendererReady()
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
expect(readRun(store, automation.id, retained.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, retained.id).error).toBe(
'Orca lost the terminal for this run before it reported completion.'
)
service.stop()
})
})
@@ -0,0 +1,103 @@
import type { AutomationRun } from '../../shared/automations-types'
/** Cadence for re-checking a retained run whose pane has not remounted yet. */
const RETRY_INTERVAL_MS = 2_000
/** Grace after the terminal surface reports ready before an unresolvable run is
* called lost. Covers restored-pane remount and SSH/WSL reattach, and matches
* the run observer's agent-start deadline. */
const SURFACE_SETTLE_MS = 2 * 60 * 1000
export type RetainedRunReconcilerDeps = {
/** Attaches a watcher; false while the terminal surface cannot answer yet. */
attach: (run: AutomationRun) => boolean
/** False once the run reached a terminal status by some other path. */
stillRetained: (run: AutomationRun) => boolean
strand: (run: AutomationRun) => void
}
/**
* Startup reconciliation of retained non-terminal runs.
*
* Why staged rather than one synchronous pass: at authority startup no window
* graph has published yet, so "this pane has no terminal" means "not known yet",
* never "the terminal is gone" — the same positive-evidence rule the design doc
* applies to ownership. Only a surface that has reported ready and still cannot
* resolve the pane after a grace window is evidence of a lost run.
*/
export class RetainedRunReconciler {
private readonly deps: RetainedRunReconcilerDeps
private readonly pending = new Map<string, AutomationRun>()
private timer: ReturnType<typeof setInterval> | null = null
private surfaceReadyAt: number | null = null
private disposed = false
constructor(deps: RetainedRunReconcilerDeps) {
this.deps = deps
}
reconcile(runs: readonly AutomationRun[]): void {
if (this.disposed) {
return
}
for (const run of runs) {
if (!this.deps.attach(run)) {
this.pending.set(run.id, run)
}
}
this.sweep()
}
/** The authority's terminal surface can now answer pane lookups. */
markSurfaceReady(): void {
if (this.disposed) {
return
}
this.surfaceReadyAt ??= Date.now()
this.sweep()
}
dispose(): void {
this.disposed = true
this.pending.clear()
this.disarm()
}
private sweep(): void {
const strandAt = this.surfaceReadyAt === null ? null : this.surfaceReadyAt + SURFACE_SETTLE_MS
// Deleting the current entry mid-iteration is defined for Map; nothing here
// re-enters the reconciler, so no snapshot is needed.
for (const [runId, run] of this.pending) {
if (!this.deps.stillRetained(run) || this.deps.attach(run)) {
this.pending.delete(runId)
continue
}
if (strandAt !== null && Date.now() >= strandAt) {
this.pending.delete(runId)
this.deps.strand(run)
}
}
if (this.pending.size === 0) {
this.disarm()
return
}
this.arm()
}
private arm(): void {
if (this.timer || this.disposed) {
return
}
const timer = setInterval(() => this.sweep(), RETRY_INTERVAL_MS)
// Why: a pending retry must never be the reason a process stays alive.
;(timer as { unref?: () => void }).unref?.()
this.timer = timer
}
private disarm(): void {
if (!this.timer) {
return
}
clearInterval(this.timer)
this.timer = null
}
}
@@ -0,0 +1,415 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
import { AutomationService } from './service'
import type {
AutomationRunCompletionObservation,
AutomationRunTerminalObserver
} from './run-completion-watcher'
const testState = { dir: '' }
const ipcHandlers = new Map<string, (event: unknown, args: unknown) => unknown>()
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => {
ipcHandlers.set(channel, handler)
}
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
async function createStore() {
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
}
const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
id: 'r1',
path: '/repo',
displayName: 'test',
badgeColor: '#fff',
addedAt: 1,
...overrides
})
type TestStore = Awaited<ReturnType<typeof createStore>>
function createAutomation(store: TestStore): Automation {
store.addRepo(makeRepo())
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
})
}
const LAUNCH_TARGET = {
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
terminalPaneKey: 'tab-1:11111111-2222-4333-8444-555555555555',
terminalPtyId: 'pty-1'
}
function readRun(store: TestStore, automationId: string, runId: string): AutomationRun {
const run = store.listAutomationRuns(automationId).find((entry) => entry.id === runId)
if (!run) {
throw new Error('run missing')
}
return run
}
function createObserver(
observe: (signal: AbortSignal) => Promise<AutomationRunCompletionObservation>,
resolveRunTerminal: (run: AutomationRun) => string | null = (run) =>
run.terminalPaneKey ? 'handle-1' : null
): AutomationRunTerminalObserver {
return {
resolveRunTerminal,
observeCompletion: (_handle, { signal }) => observe(signal)
}
}
describe('authority-owned automation run completion', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-completion-'))
ipcHandlers.clear()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('brings a headless dispatched run to a terminal state with no renderer attached', async () => {
const store = await createStore()
const automation = createAutomation(store)
const service = new AutomationService(store, {
// Why: a dispatcher without a completion promise is exactly the case that
// used to strand a run at 'dispatched' for the process lifetime.
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: createObserver(async () => ({ status: 'completed', error: null }))
})
const run = await service.runNow(automation.id)
expect(run.status).toBe('dispatched')
await vi.waitFor(() => {
expect(readRun(store, automation.id, run.id).status).toBe('completed')
})
service.stop()
})
it('leaves a headless dispatched run alone when the authority cannot observe it', async () => {
const store = await createStore()
const automation = createAutomation(store)
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: createObserver(
async () => ({ status: 'completed' }),
() => null
)
})
const run = await service.runNow(automation.id)
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
service.stop()
})
/**
* The throws on this path carry transport tokens, not sentences. Every other
* refusal in run history is one fixed sentence, so leaking `terminal_handle_stale`
* into the row a user reads breaks the only convention that surface has.
*/
it('reports a failed observation in the same fixed sentence every other refusal uses', async () => {
const store = await createStore()
const automation = createAutomation(store)
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: createObserver(async () => {
throw new Error('terminal_handle_stale')
})
})
const run = await service.runNow(automation.id)
await vi.waitFor(() => {
expect(readRun(store, automation.id, run.id).status).toBe('dispatch_failed')
})
expect(readRun(store, automation.id, run.id).error).toBe(
'Orca stopped watching this run before it reported completion.'
)
// The token is still recoverable where it is actually useful.
expect(logged.mock.calls.flat().map(String).join(' ')).toContain('terminal_handle_stale')
logged.mockRestore()
service.stop()
})
it('reconciles stranded runs on startup without claiming completion', async () => {
const store = await createStore()
const automation = createAutomation(store)
const dispatched = store.createAutomationRun(automation, 1_000, 'manual')
store.updateAutomationRun({
runId: dispatched.id,
status: 'dispatched',
...LAUNCH_TARGET,
error: null
})
const dispatching = store.createAutomationRun(automation, 2_000, 'manual')
store.updateAutomationRun({
runId: dispatching.id,
status: 'dispatching',
workspaceId: 'wt1',
error: null
})
const service = new AutomationService(store, {
terminalObserver: createObserver(
async () => ({ status: 'completed' }),
() => null
)
})
vi.useFakeTimers()
service.start()
// Stranding needs a surface that reported ready and still cannot find the pane.
service.setRendererReady()
await vi.advanceTimersByTimeAsync(10 * 60 * 1000)
expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal')
expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started')
service.stop()
vi.useRealTimers()
})
it('re-attaches a watcher on startup when the session survived', async () => {
const store = await createStore()
const automation = createAutomation(store)
const run = store.createAutomationRun(automation, 1_000, 'manual')
store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
...LAUNCH_TARGET,
error: null
})
let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null
const service = new AutomationService(store, {
terminalObserver: createObserver(
() =>
new Promise<AutomationRunCompletionObservation>((resolve) => {
resolveObservation = resolve
})
)
})
service.start()
await vi.waitFor(() => expect(resolveObservation).not.toBeNull())
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
resolveObservation!({ status: 'completed', error: null })
await vi.waitFor(() => {
expect(readRun(store, automation.id, run.id).status).toBe('completed')
})
service.stop()
})
it('persists the terminal status once when the renderer wins the race', async () => {
const store = await createStore()
const automation = createAutomation(store)
let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: createObserver(
() =>
new Promise<AutomationRunCompletionObservation>((resolve) => {
resolveObservation = resolve
})
)
})
const run = await service.runNow(automation.id)
await vi.waitFor(() => expect(resolveObservation).not.toBeNull())
// The renderer's own dispatch observer reports first.
await service.markDispatchResult({
runId: run.id,
status: 'completed',
...LAUNCH_TARGET,
error: null
})
const afterRenderer = readRun(store, automation.id, run.id)
const writeSpy = vi.spyOn(store, 'updateAutomationRun')
resolveObservation!({ status: 'dispatch_failed', error: 'watcher lost the terminal' })
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(writeSpy).not.toHaveBeenCalled()
const afterWatcher = readRun(store, automation.id, run.id)
expect(afterWatcher.status).toBe('completed')
expect(afterWatcher.error).toBeNull()
expect(afterWatcher.usage).toEqual(afterRenderer.usage)
writeSpy.mockRestore()
service.stop()
})
it('disposes watchers on stop', async () => {
const store = await createStore()
const automation = createAutomation(store)
const aborted: boolean[] = []
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
terminalObserver: createObserver(
(signal) =>
new Promise<AutomationRunCompletionObservation>((_resolve, reject) => {
signal.addEventListener('abort', () => {
aborted.push(true)
reject(new Error('aborted'))
})
})
)
})
const run = await service.runNow(automation.id)
service.stop()
await vi.waitFor(() => expect(aborted).toEqual([true]))
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
})
it('keeps a run dispatched against an authority with no completion watching', async () => {
// Why: the New client | Old runtime matrix row — the old authority publishes
// no event and reconciles nothing, so the client must not crash or invent a
// terminal status; TTL/focus revalidation stays the only refresh path.
const store = await createStore()
const automation = createAutomation(store)
const publish = vi.fn()
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
onAutomationsChanged: publish
})
const run = await service.runNow(automation.id)
service.start()
await Promise.resolve()
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
service.stop()
})
})
describe('automationsChanged publication', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-events-'))
ipcHandlers.clear()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('publishes run and usage changes after the write is persisted', async () => {
const store = await createStore()
const automation = createAutomation(store)
const seen: { payload: AutomationsChangedPayload; status: string | undefined }[] = []
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
onAutomationsChanged: (payload) => {
seen.push({
payload,
status: store.listAutomationRuns(automation.id)[0]?.status
})
}
})
const run = await service.runNow(automation.id)
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run'])
expect(seen.at(-1)?.status).toBe('dispatched')
await service.markDispatchResult({
runId: run.id,
status: 'completed',
...LAUNCH_TARGET,
error: null
})
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'usage'])
expect(seen.at(-1)?.status).toBe('completed')
// Every run/usage write names its own host, so one automation's run cannot
// invalidate the rest of the authority.
expect(seen.every((entry) => entry.payload.selector?.kind === 'self')).toBe(true)
service.stop()
})
it('publishes definition changes from the desktop IPC handlers', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const publish = vi.fn()
const service = new AutomationService(store, { onAutomationsChanged: publish })
const { registerAutomationHandlers } = await import('../ipc/automations')
registerAutomationHandlers(store, service)
const created = (await ipcHandlers.get('automations:create')!(null, {
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
})) as Automation
// Definition changes name the host they affected; a move would publish source and destination.
expect(publish).toHaveBeenLastCalledWith({
reason: 'definition',
selector: { kind: 'self' }
})
await ipcHandlers.get('automations:update')!(null, {
id: created.id,
updates: { enabled: false }
})
expect(publish).toHaveBeenCalledTimes(2)
await ipcHandlers.get('automations:delete')!(null, { id: created.id })
expect(publish).toHaveBeenCalledTimes(3)
expect(store.listAutomations()).toHaveLength(0)
service.stop()
})
it('changes nothing for a consumer that ignores the unknown event type', async () => {
const store = await createStore()
const automation = createAutomation(store)
// An old client's event switch has no automationsChanged branch at all.
const oldClientState = { repoRefreshes: 0 }
const service = new AutomationService(store, {
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
onAutomationsChanged: (payload) => {
if ((payload as { type?: string }).type === 'reposChanged') {
oldClientState.repoRefreshes += 1
}
}
})
const run = await service.runNow(automation.id)
expect(oldClientState.repoRefreshes).toBe(0)
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
service.stop()
})
})
@@ -0,0 +1,182 @@
import {
isFinalAutomationRunStatus,
type AutomationDispatchResult,
type AutomationRun,
type AutomationRunOutputSnapshot
} from '../../shared/automations-types'
import { RetainedRunReconciler } from './retained-run-reconciliation'
export type AutomationRunCompletionObservation = {
status: 'completed' | 'dispatch_failed'
outputSnapshot?: AutomationRunOutputSnapshot | null
error?: string | null
}
/** The owning authority's view of a dispatched run's own terminal session. */
export type AutomationRunTerminalObserver = {
/** Control handle for the run's terminal, or null once the authority lost it. */
resolveRunTerminal: (run: AutomationRun) => string | null
observeCompletion: (
handle: string,
options: { signal: AbortSignal }
) => Promise<AutomationRunCompletionObservation>
}
/** Truthful reason for a run this authority can no longer observe; never claims completion. */
export function describeStrandedAutomationRun(run: AutomationRun): string {
if (run.status === 'dispatching') {
return 'Orca stopped before this run reported that its agent started.'
}
return 'Orca lost the terminal for this run before it reported completion.'
}
/** Why a fixed sentence: the throws here carry internal transport tokens
* (`terminal_handle_stale`, `terminal_not_found`, `request_aborted`), and a run
* history row is user copy. The token stays in the log, where it is useful. */
function describeObservationError(error: unknown): string {
console.error('[automations] run completion observation failed:', error)
return 'Orca stopped watching this run before it reported completion.'
}
export class AutomationRunCompletionWatcher {
private readonly observer: AutomationRunTerminalObserver
private readonly readRun: (automationId: string, runId: string) => AutomationRun | null
private readonly markDispatchResult: (result: AutomationDispatchResult) => Promise<unknown>
private readonly watching = new Map<string, AbortController>()
private readonly reconciler: RetainedRunReconciler
private disposed = false
constructor(opts: {
observer: AutomationRunTerminalObserver
readRun: (automationId: string, runId: string) => AutomationRun | null
markDispatchResult: (result: AutomationDispatchResult) => Promise<unknown>
}) {
this.observer = opts.observer
this.readRun = opts.readRun
this.markDispatchResult = opts.markDispatchResult
this.reconciler = new RetainedRunReconciler({
attach: (run) => this.attachRetainedRun(run),
stillRetained: (run) => {
const current = this.readRun(run.automationId, run.id)
return Boolean(current && !isFinalAutomationRunStatus(current.status))
},
strand: (run) => {
void this.finalize(run, {
status: 'dispatch_failed',
error: describeStrandedAutomationRun(run)
}).catch((error) => {
console.error('[automations] failed to reconcile stranded run:', error)
})
}
})
}
/** Observes a just-dispatched run. A run whose terminal is not resolvable yet
* is left alone; startup reconciliation is what resolves stranded runs. */
watch(run: AutomationRun): void {
if (this.disposed || this.watching.has(run.id)) {
return
}
const handle = this.observer.resolveRunTerminal(run)
if (handle) {
this.startWatch(run, handle)
}
}
private attachRetainedRun(run: AutomationRun): boolean {
if (this.disposed) {
return false
}
if (this.watching.has(run.id)) {
return true
}
const handle = this.observer.resolveRunTerminal(run)
if (!handle) {
return false
}
this.startWatch(run, handle)
return true
}
private startWatch(run: AutomationRun, handle: string): void {
const controller = new AbortController()
this.watching.set(run.id, controller)
void this.observeUntilTerminal(run, handle, controller).finally(() => {
if (this.watching.get(run.id) === controller) {
this.watching.delete(run.id)
}
})
}
private async observeUntilTerminal(
run: AutomationRun,
handle: string,
controller: AbortController
): Promise<void> {
let observation: AutomationRunCompletionObservation
try {
observation = await this.observer.observeCompletion(handle, { signal: controller.signal })
} catch (error) {
if (controller.signal.aborted) {
return
}
observation = { status: 'dispatch_failed', error: describeObservationError(error) }
}
try {
await this.finalize(run, observation)
} catch (error) {
console.error('[automations] failed to persist watched run completion:', error)
}
}
forget(runId: string): void {
this.watching.get(runId)?.abort()
this.watching.delete(runId)
}
/** Startup pass over retained runs: re-attach where the session survived, and
* stage the rest — a run is only closed out once the terminal surface has
* reported ready and still cannot find it. */
reconcileRetainedRuns(runs: readonly AutomationRun[]): void {
this.reconciler.reconcile(
runs.filter((run) => run.status === 'dispatched' || run.status === 'dispatching')
)
}
/** The authority's terminal surface can now answer pane lookups: desktop
* renderer attached, or headless serve finished adopting its PTYs. */
markTerminalSurfaceReady(): void {
this.reconciler.markSurfaceReady()
}
dispose(): void {
this.disposed = true
this.reconciler.dispose()
for (const controller of this.watching.values()) {
controller.abort()
}
this.watching.clear()
}
private async finalize(
run: AutomationRun,
observation: AutomationRunCompletionObservation
): Promise<void> {
// Why: the renderer's dispatch observer races this watcher for the same run;
// re-reading immediately before the write keeps the terminal status single.
const current = this.readRun(run.automationId, run.id)
if (!current || isFinalAutomationRunStatus(current.status)) {
return
}
await this.markDispatchResult({
runId: run.id,
status: observation.status,
workspaceId: current.workspaceId,
terminalSessionId: current.terminalSessionId,
terminalPaneKey: current.terminalPaneKey,
terminalPtyId: current.terminalPtyId,
outputSnapshot: observation.outputSnapshot ?? null,
error: observation.error ?? null
})
}
}
@@ -76,7 +76,9 @@ function makeAutomation(runContext: WorkspaceRunContext): Automation {
function makeStore(setups: ProjectHostSetup[], repos: Repo[]): Store {
return {
getProjectHostSetups: () => setups,
getRepo: (id: string) => repos.find((repo) => repo.id === id)
getRepo: (id: string) => repos.find((repo) => repo.id === id),
// Local records here; the generation fence has its own file against a real Store.
automationCapturedHostIssue: () => null
} as unknown as Store
}
@@ -1,6 +1,10 @@
import type { Store } from '../persistence'
import type { Automation } from '../../shared/automations-types'
import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity'
import {
AUTOMATION_ORPHAN_ISSUES,
type AutomationCapturedHostIssue
} from '../../shared/automation-list-scope'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
@@ -10,6 +14,21 @@ export type AutomationRunTargetResult =
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
| { ok: false; error: string }
/**
* One fixed sentence per diagnosis, and never the same sentence twice: run
* coalescing folds repeats on identical error text, so a message that varied per
* occurrence would write a row each, and a shared one would merge a host that is
* gone with a host that was replaced.
*/
const CAPTURED_HOST_REFUSALS: Record<AutomationCapturedHostIssue, string> = {
[AUTOMATION_ORPHAN_ISSUES.targetMissing]:
'The automation host is no longer registered, so this automation has nowhere to run.',
[AUTOMATION_ORPHAN_ISSUES.targetReplaced]:
'The automation host was removed and re-registered, so this automation must be re-adopted before it can run.',
[AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous]:
'The automation workspace spans more than one host, so Orca cannot tell which one to run it on.'
}
type AutomationRunTargetOptions = {
allowRemoteHostScheduling?: boolean
}
@@ -50,6 +69,16 @@ export function resolveAutomationRunTarget(
}
}
// Why: removing a host — or removing and re-registering it under the same id —
// keeps hostId, repoId and path intact, so every check below still passes while
// the host is gone or is a different machine. Asks the list's projection for the
// verdict rather than re-deriving one, so the row the user sees as orphaned can
// never be the row that quietly keeps firing.
const hostIssue = store.automationCapturedHostIssue(automation)
if (hostIssue) {
return { ok: false, error: CAPTURED_HOST_REFUSALS[hostIssue] }
}
const setup = store
.getProjectHostSetups()
.find((candidate) => candidate.id === context.projectHostSetupId)
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRuntimeAutomationRunTerminalObserver } from './runtime-terminal-run-observer'
import type { AutomationRunTerminalHost } from './runtime-terminal-run-observer'
import type { AutomationRunCompletionObservation } from './run-completion-watcher'
const HANDLE = 'terminal-1'
const RUNTIME_TUI_IDLE_TIMEOUT_MS = 5 * 60 * 1000
/** The three shapes the runtime satisfies tui-idle from. `lastAgentStatus` is the
* sticky pty record, `paneTitle` the leaf branch's inlined title read, `preview`
* the ready-shell-prompt match. All three are level, none require an edge. */
type FakePane = {
lastAgentStatus: 'idle' | 'working' | 'permission' | null
paneTitle: string | null
preview: string
}
type FakeWaiter = {
resolve: (value: { satisfied: boolean; blockedReason?: string }) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
function createFakeRuntime(initial: Partial<FakePane>) {
const pane: FakePane = {
lastAgentStatus: null,
paneTitle: null,
preview: '',
...initial
}
const waiters = new Set<FakeWaiter>()
let waitCalls = 0
// Mirrors the runtime's tui-idle satisfaction: sticky status, idle title, or a
// ready shell prompt — whichever is true at the instant of the read.
const satisfiedNow = (): boolean =>
pane.lastAgentStatus === 'idle' ||
(pane.paneTitle?.toLowerCase().includes('idle') ?? false) ||
pane.preview.trimEnd().endsWith('$')
const runtime: AutomationRunTerminalHost & {
setPane: (next: Partial<FakePane>) => void
waitCalls: () => number
} = {
getTerminalHandleForPaneKey: () => HANDLE,
readTerminal: async () => ({ tail: ['previous run output'] }),
waitForTerminal: (_handle, options) => {
waitCalls += 1
if (options?.signal?.aborted) {
return Promise.reject(new Error('request_aborted'))
}
if (satisfiedNow()) {
return Promise.resolve({ satisfied: true })
}
return new Promise((resolve, reject) => {
const waiter: FakeWaiter = {
resolve,
reject,
timer: setTimeout(() => {
waiters.delete(waiter)
reject(new Error('timeout'))
}, options?.timeoutMs ?? RUNTIME_TUI_IDLE_TIMEOUT_MS)
}
waiters.add(waiter)
})
},
setPane: (next) => {
Object.assign(pane, next)
if (!satisfiedNow()) {
return
}
for (const waiter of waiters) {
waiters.delete(waiter)
clearTimeout(waiter.timer)
waiter.resolve({ satisfied: true })
}
},
waitCalls: () => waitCalls
}
return runtime
}
function observe(runtime: AutomationRunTerminalHost) {
const controller = new AbortController()
const settled: AutomationRunCompletionObservation[] = []
const errors: unknown[] = []
const observer = createRuntimeAutomationRunTerminalObserver(runtime)
const promise = observer
.observeCompletion(HANDLE, { signal: controller.signal })
.then((observation) => {
settled.push(observation)
})
.catch((error: unknown) => {
errors.push(error)
})
return { controller, settled, errors, promise }
}
describe('createRuntimeAutomationRunTerminalObserver', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not complete a reused run from the previous run idle status', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'idle' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(10_000)
expect(run.settled).toEqual([])
expect(run.errors).toEqual([])
runtime.setPane({ lastAgentStatus: 'working' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled).toEqual([])
runtime.setPane({ lastAgentStatus: 'idle' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled[0]?.status).toBe('completed')
await run.promise
})
it('does not complete from a stale idle pane title (leaf branch shape)', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: null, paneTitle: '✳ Claude — idle' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(10_000)
expect(run.settled).toEqual([])
runtime.setPane({ paneTitle: '✳ Claude — working' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled).toEqual([])
runtime.setPane({ paneTitle: '✳ Claude — idle' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled[0]?.status).toBe('completed')
await run.promise
})
it('does not complete from a ready shell prompt left over at dispatch', async () => {
const runtime = createFakeRuntime({ preview: 'user@host repo %\n$' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(10_000)
expect(run.settled).toEqual([])
runtime.setPane({ preview: 'claude is thinking…' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled).toEqual([])
runtime.setPane({ lastAgentStatus: 'idle' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled[0]?.status).toBe('completed')
await run.promise
})
it('fails the run truthfully when the pane never leaves its pre-dispatch state', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'idle' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(2 * 60 * 1000 + 1_000)
expect(run.settled[0]?.status).toBe('dispatch_failed')
expect(run.settled[0]?.error).toContain('never started')
await run.promise
})
it('completes a fresh launch that was never idle at dispatch', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'working' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled).toEqual([])
runtime.setPane({ lastAgentStatus: 'idle' })
await vi.advanceTimersByTimeAsync(10)
expect(run.settled[0]?.status).toBe('completed')
expect(run.settled[0]?.outputSnapshot?.content).toContain('previous run output')
await run.promise
})
it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'working' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 + RUNTIME_TUI_IDLE_TIMEOUT_MS)
expect(run.settled[0]?.status).toBe('dispatch_failed')
expect(run.settled[0]?.error).toContain('without a completion signal')
// 6h of 5-minute waits, not an unbounded re-arm.
expect(runtime.waitCalls()).toBeLessThanOrEqual(80)
await run.promise
})
})
@@ -0,0 +1,198 @@
import { createHeadlessAutomationOutputSnapshotBuffer } from './headless-dispatch'
import type {
AutomationRunCompletionObservation,
AutomationRunTerminalObserver
} from './run-completion-watcher'
import type { AutomationRunOutputSnapshot } from '../../shared/automations-types'
const TERMINAL_SNAPSHOT_LIMIT = 2_000
/** Cadence for re-probing a pane that already satisfied tui-idle at dispatch. */
const AGENT_START_POLL_INTERVAL_MS = 250
/** Kept under the runtime's 2s tui-idle fallback poll so a probe waiter is torn
* down before it can start a foreground-process poll of its own. */
const AGENT_START_PROBE_TIMEOUT_MS = 250
/** How long a pane may stay in its pre-dispatch state before we stop believing
* the prompt reached an agent. Covers the deliberate pre-Enter delay plus agent
* spin-up over SSH; past that, silence is not evidence of work. */
const AGENT_START_DEADLINE_MS = 2 * 60 * 1000
/** Total observation budget. Each tui-idle wait expires on the runtime's own
* 5-minute schedule and a live agent legitimately outlives many of them, so the
* bound is wall-clock and generous; it exists so a pane whose agent is never
* detected stops re-arming for the process lifetime with its run stuck at
* `dispatched`. Startup reconciliation re-attaches, so this is not a hard cap on
* how long a surviving run may be watched across restarts. */
const OBSERVE_DEADLINE_MS = 6 * 60 * 60 * 1000
/** The runtime surface an authority uses to observe its own terminals. */
export type AutomationRunTerminalHost = {
getTerminalHandleForPaneKey(paneKey: string): string | null
waitForTerminal(
handle: string,
options?: { condition?: 'tui-idle'; timeoutMs?: number; signal?: AbortSignal }
): Promise<{ satisfied: boolean; blockedReason?: string }>
readTerminal(handle: string, opts?: { limit?: number }): Promise<{ tail: string[] }>
}
function isTerminalWaitTimeout(error: unknown): boolean {
return error instanceof Error && error.message === 'timeout'
}
async function sleep(ms: number, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new Error('request_aborted'))
return
}
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('request_aborted'))
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
/** Whether tui-idle is satisfiable from evidence the pane holds RIGHT NOW.
* Why a real wait with a short timeout rather than a status read: the runtime
* satisfies tui-idle from a sticky agent status, an idle pane title, or a ready
* shell prompt, across two different code shapes (pty vs leaf). Asking it the
* question it already answers keeps every one of those in scope. */
async function isTuiIdleSatisfiedNow(
runtime: AutomationRunTerminalHost,
handle: string,
signal: AbortSignal
): Promise<boolean> {
try {
const wait = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs: AGENT_START_PROBE_TIMEOUT_MS,
signal
})
// A blocked pane is not "already finished"; let the real wait report it.
return wait.satisfied
} catch (error) {
if (isTerminalWaitTimeout(error)) {
return false
}
throw error
}
}
/** Polls until the pane stops satisfying tui-idle — the edge that proves this
* run's agent, not the previous one, owns the pane. */
async function waitForAgentStart(
runtime: AutomationRunTerminalHost,
handle: string,
signal: AbortSignal,
deadlineAt: number
): Promise<boolean> {
while (Date.now() < deadlineAt) {
await sleep(AGENT_START_POLL_INTERVAL_MS, signal)
if (!(await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
return true
}
}
return false
}
async function readTerminalSnapshot(
runtime: AutomationRunTerminalHost,
handle: string
): Promise<AutomationRunOutputSnapshot | null> {
const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer()
try {
const read = await runtime.readTerminal(handle, { limit: TERMINAL_SNAPSHOT_LIMIT })
snapshotBuffer.append(read.tail.join('\n'))
} catch {
// Why: the terminal can exit between the wait resolving and the tail read;
// a missing snapshot must not turn a satisfied wait into a failure.
}
return snapshotBuffer.snapshot()
}
async function buildObservation(
runtime: AutomationRunTerminalHost,
handle: string,
wait: { satisfied: boolean; blockedReason?: string }
): Promise<AutomationRunCompletionObservation> {
const outputSnapshot = await readTerminalSnapshot(runtime, handle)
if (wait.satisfied) {
return { status: 'completed', outputSnapshot, error: null }
}
return {
status: 'dispatch_failed',
outputSnapshot,
error: wait.blockedReason
? `Automation agent is blocked: ${wait.blockedReason}.`
: 'Automation agent did not report completion.'
}
}
/** Closes a run out without claiming a completion nobody observed. */
async function buildUnobservedObservation(
runtime: AutomationRunTerminalHost,
handle: string,
error: string
): Promise<AutomationRunCompletionObservation> {
return {
status: 'dispatch_failed',
outputSnapshot: await readTerminalSnapshot(runtime, handle),
error
}
}
export function createRuntimeAutomationRunTerminalObserver(
runtime: AutomationRunTerminalHost
): AutomationRunTerminalObserver {
return {
resolveRunTerminal: (run) =>
run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null,
observeCompletion: async (handle, { signal }) => {
const startedAt = Date.now()
// Why: tui-idle is level-triggered, so a reused pane still idle from the
// PREVIOUS run satisfies it before this run's agent has typed a character.
// Evidence that predates dispatch proves nothing about this run, so require
// the pane to leave that state first — the busy edge the renderer's own
// dispatch observer requires on reuse (requireWorkingAfterStart).
if (await isTuiIdleSatisfiedNow(runtime, handle, signal)) {
const started = await waitForAgentStart(
runtime,
handle,
signal,
startedAt + AGENT_START_DEADLINE_MS
)
if (!started) {
return await buildUnobservedObservation(
runtime,
handle,
'Automation agent never started after the prompt was submitted.'
)
}
}
const deadlineAt = startedAt + OBSERVE_DEADLINE_MS
for (;;) {
try {
const wait = await runtime.waitForTerminal(handle, { condition: 'tui-idle', signal })
return await buildObservation(runtime, handle, wait)
} catch (error) {
// Why: tui-idle waits expire on their own schedule; an agent still
// working past that window is live, so re-arm rather than fail it.
if (signal.aborted || !isTerminalWaitTimeout(error)) {
throw error
}
if (Date.now() >= deadlineAt) {
return await buildUnobservedObservation(
runtime,
handle,
'Orca stopped watching this run after 6h without a completion signal.'
)
}
}
}
}
}
}
+11 -13
View File
@@ -4,6 +4,8 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import { AutomationService } from './service'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
import { createAutomationRunWriter } from './automation-run-writer'
const runAutomationPrecheckMock = vi.hoisted(() => vi.fn())
const testState = { dir: '' }
@@ -204,20 +206,16 @@ describe('AutomationService prechecks', () => {
headlessDispatcher
})
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
const requestHeadlessDispatch = (
service as unknown as {
requestHeadlessDispatch: (
automationArg: typeof automation,
runArg: typeof run,
targetArg: { ok: true; cwd: string; repo: Repo }
) => Promise<unknown>
}
).requestHeadlessDispatch.bind(service)
await requestHeadlessDispatch(automation, run, {
ok: true,
cwd: '/repo',
repo: store.getRepo('r1')!
await runHeadlessAutomationDispatch({
automation,
run,
target: { ok: true, cwd: '/repo', repo: store.getRepo('r1')! },
dispatcher: headlessDispatcher,
runs: createAutomationRunWriter(store, null),
runPrecheck: () => service.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => service.markDispatchResult(result),
watchRun: () => {}
})
expect(headlessDispatcher).not.toHaveBeenCalled()
+113 -91
View File
@@ -15,10 +15,21 @@ import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './ru
import { collectAutomationRunUsage } from './run-usage-collection'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../shared/automation-precheck'
AutomationRunCompletionWatcher,
type AutomationRunTerminalObserver
} from './run-completion-watcher'
import { createAutomationRunWriter, type AutomationRunWriter } from './automation-run-writer'
import {
describeScheduledRefusal,
recordRefusedAutomationRun,
NO_DISPATCH_HOST
} from './dispatch-refusal'
import type {
AutomationsChangedPayload,
PublishAutomationsChanged
} from '../../shared/runtime-client-events'
const DEFAULT_TICK_MS = 60 * 1000
@@ -33,6 +44,9 @@ export class AutomationService {
private readonly codexUsage: CodexUsageStore | null
private readonly allowRemoteHostScheduling: boolean
private readonly headlessDispatcher: HeadlessAutomationDispatcher | null
private readonly publish: PublishAutomationsChanged | null
private readonly runs: AutomationRunWriter
private readonly completionWatcher: AutomationRunCompletionWatcher | null
constructor(
store: Store,
@@ -42,6 +56,8 @@ export class AutomationService {
codexUsage?: CodexUsageStore
allowRemoteHostScheduling?: boolean
headlessDispatcher?: HeadlessAutomationDispatcher
terminalObserver?: AutomationRunTerminalObserver
onAutomationsChanged?: PublishAutomationsChanged
} = {}
) {
this.store = store
@@ -50,6 +66,22 @@ export class AutomationService {
this.codexUsage = opts.codexUsage ?? null
this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false
this.headlessDispatcher = opts.headlessDispatcher ?? null
this.publish = opts.onAutomationsChanged ?? null
this.runs = createAutomationRunWriter(store, this.publish)
this.completionWatcher = opts.terminalObserver
? new AutomationRunCompletionWatcher({
observer: opts.terminalObserver,
readRun: (automationId, runId) =>
this.store.listAutomationRuns(automationId).find((entry) => entry.id === runId) ?? null,
markDispatchResult: (result) => this.markDispatchResult(result)
})
: null
}
/** CRUD callers publish through the service so every authority write lands on
* the same local + runtime client-event pair. */
publishAutomationsChanged(payload: AutomationsChangedPayload = {}): void {
this.publish?.(payload)
}
setWebContents(webContents: WebContents | null): void {
@@ -59,6 +91,9 @@ export class AutomationService {
setRendererReady(): void {
this.rendererReady = true
// Why: the renderer publishes the desktop window graph, so only after it
// attaches can an unresolvable pane mean a lost terminal rather than "not yet".
this.completionWatcher?.markTerminalSurfaceReady()
void this.evaluateDueRuns()
}
@@ -69,14 +104,19 @@ export class AutomationService {
this.timer = setInterval(() => {
void this.evaluateDueRuns()
}, this.tickMs)
this.completionWatcher?.reconcileRetainedRuns(this.store.listAutomationRuns())
// Why: headless serve never gets a renderer-ready IPC, but due runs still
// need the same startup catch-up pass desktop gets after renderer attach.
if (this.rendererReady || this.headlessDispatcher) {
// Serve adopts its daemon PTYs and publishes its graph before start(), so
// its terminal surface is already as answerable as it will get.
this.completionWatcher?.markTerminalSurfaceReady()
void this.evaluateDueRuns()
}
}
stop(): void {
this.completionWatcher?.dispose()
if (!this.timer) {
return
}
@@ -89,8 +129,21 @@ export class AutomationService {
if (!automation) {
throw new Error('Automation not found.')
}
const run = this.store.createAutomationRun(automation, Date.now(), 'manual')
return await this.requestDispatch(automation, run)
const run = this.runs.createRun(automation, Date.now(), 'manual')
return await this.requestDispatch(automation, run, this.resolveTarget(automation))
}
/** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */
recordRefusedRun(automationId: string): void {
const automation = this.store.listAutomations().find((entry) => entry.id === automationId)
if (automation) {
recordRefusedAutomationRun({
store: this.store,
runs: this.runs,
automation,
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
}
}
async runPrecheck(automationId: string, runId: string): Promise<AutomationPrecheckResult | null> {
@@ -105,9 +158,7 @@ export class AutomationService {
if (run.trigger !== 'scheduled' || !automation.precheck) {
return null
}
const target = resolveAutomationRunTarget(this.store, automation, {
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
const target = this.resolveTarget(automation)
if (!target.ok) {
return {
command: automation.precheck.command,
@@ -133,11 +184,15 @@ export class AutomationService {
}
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
const run = this.store.updateAutomationRun(result)
const run = this.runs.updateRun(result)
clearAutomationDispatchTokens(run.automationId, run.id)
if (!isFinalAutomationRunStatus(run.status)) {
if (run.status === 'dispatched') {
this.completionWatcher?.watch(run)
}
return run
}
this.completionWatcher?.forget(run.id)
// Why: the renderer's mark-completed effect can re-fire for the same run
// before refresh() flips its status snapshot off 'dispatched'. Re-running
// collectRunUsage advances the attribution window and can rewrite an
@@ -156,7 +211,7 @@ export class AutomationService {
if (!this.store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) {
return run
}
return this.store.updateAutomationRun({
return this.runs.updateRun({
runId: run.id,
status: run.status,
workspaceId: run.workspaceId,
@@ -190,11 +245,11 @@ export class AutomationService {
this.store.advanceAutomationNextRun(automation.id, now)
return
}
const run = this.store.createAutomationRun(automation, scheduledFor)
const graceMs = automation.missedRunGraceMinutes * 60 * 1000
if (now - scheduledFor > graceMs) {
this.store.updateAutomationRun({
runId: run.id,
const missed = this.runs.createRun(automation, scheduledFor)
this.runs.updateRun({
runId: missed.id,
status: 'skipped_missed',
workspaceId: automation.workspaceId,
error: 'Orca was unavailable during the missed-run grace window.'
@@ -203,38 +258,70 @@ export class AutomationService {
return
}
await this.requestDispatch(automation, run)
// Resolved before the run exists: a refusal repeats every occurrence, and a
// */5 automation would otherwise write ~288 identical rows a day — past
// retention, which would evict the automation's real history.
const target = this.resolveTarget(automation)
const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() })
if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) {
this.store.advanceAutomationNextRun(automation.id, now)
return
}
await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target)
this.store.advanceAutomationNextRun(automation.id, now)
}
private resolveTarget(automation: Automation): AutomationRunTargetResult {
return resolveAutomationRunTarget(this.store, automation, {
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
}
private canDispatchToRenderer(): boolean {
const webContents = this.webContents
return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady)
}
/** Headless serve counts: it launches runs with no window at all. */
private canDispatch(): boolean {
return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher)
}
private async requestDispatch(
automation: Automation,
run: AutomationRun
run: AutomationRun,
target: AutomationRunTargetResult
): Promise<AutomationRun> {
const target = resolveAutomationRunTarget(this.store, automation, {
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
if (!target.ok) {
return this.store.updateAutomationRun({
return this.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: target.error
})
}
const webContents = this.webContents
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
if (!this.canDispatchToRenderer()) {
if (this.headlessDispatcher) {
return await this.requestHeadlessDispatch(automation, run, target)
return await runHeadlessAutomationDispatch({
automation,
run,
target,
dispatcher: this.headlessDispatcher,
runs: this.runs,
runPrecheck: () => this.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => this.markDispatchResult(result),
watchRun: (dispatched) => this.completionWatcher?.watch(dispatched)
})
}
return this.store.updateAutomationRun({
return this.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: 'No Orca window was available to launch the automation.'
error: NO_DISPATCH_HOST
})
}
const updated = this.store.updateAutomationRun({
const updated = this.runs.updateRun({
runId: run.id,
status: 'dispatching',
workspaceId: automation.workspaceId,
@@ -245,72 +332,7 @@ export class AutomationService {
run: updated,
dispatchToken: createAutomationDispatchToken(automation.id, updated.id)
}
webContents.send('automations:dispatchRequested', payload)
this.webContents?.send('automations:dispatchRequested', payload)
return updated
}
private async requestHeadlessDispatch(
automation: Automation,
run: AutomationRun,
target: Extract<AutomationRunTargetResult, { ok: true }>
): Promise<AutomationRun> {
const precheckResult =
run.trigger === 'scheduled' && automation.precheck
? await this.runPrecheck(automation.id, run.id)
: null
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_precheck',
workspaceId: automation.workspaceId,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
}
try {
const launch = await this.headlessDispatcher!({ automation, run, target })
const launchRunTarget = {
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = this.store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
...launchRunTarget,
error: null
})
if (launch.completion) {
void launch.completion
.then((completion) =>
this.markDispatchResult({
runId: run.id,
status: completion.status,
...launchRunTarget,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
})
)
.catch((error) =>
this.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
...launchRunTarget,
error: error instanceof Error ? error.message : String(error)
})
)
}
return updated
} catch (error) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'dispatch_failed',
workspaceId: automation.workspaceId,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
+3
View File
@@ -313,6 +313,7 @@ import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer'
import { AgentAwakeService } from './agent-awake-service'
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
@@ -2742,6 +2743,8 @@ void app.whenReady().then(async () => {
automations = new AutomationService(store, {
claudeUsage,
codexUsage,
terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService),
onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload),
// Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules.
allowRemoteHostScheduling: isServeMode,
headlessDispatcher: isServeMode
@@ -0,0 +1,177 @@
/**
* A definition change must name the host it affected, and a change that moves a
* record between hosts must name both — a subscriber that never hears about the
* source keeps rendering a row that has left it.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
const testState = { dir: '' }
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const REPOS = [
{ id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 },
{
id: 'repo-ssh',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 2,
connectionId: 'ssh-1'
}
] as Repo[]
const TARGETS = [
{ id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 }
] as SshTarget[]
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'local-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-local',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const AUTOMATIONS = [
automation({}),
automation({
id: 'ssh-1-a',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 7
}),
automation({
id: 'orphan-1',
projectId: 'repo-ssh',
executionTargetType: 'ssh',
executionTargetId: 'ssh-gone',
executionTargetGeneration: 3
})
]
async function registerHandlers() {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
repos: REPOS,
sshTargets: TARGETS,
sshTargetGenerationCounter: 7,
automations: AUTOMATIONS
}),
'utf-8'
)
vi.resetModules()
handlers.clear()
const { Store, initDataPath } = await import('../persistence')
initDataPath()
const store = new Store()
const published: AutomationsChangedPayload[] = []
const service = {
publishAutomationsChanged: (payload: AutomationsChangedPayload) => published.push(payload)
}
const { registerAutomationHandlers } = await import('./automations')
registerAutomationHandlers(store, service as never)
return { store, published }
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'automation-publish-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
describe('scoped automationsChanged publication', () => {
it('names the host a delete removed a row from', async () => {
const { published } = await registerHandlers()
await handlers.get('automations:delete')?.(null, {
id: 'ssh-1-a',
expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }
})
expect(published).toEqual([
{ reason: 'definition', selector: { kind: 'ssh', targetId: 'ssh-1' } }
])
})
it('names the orphan bucket when an unowned row is deleted', async () => {
const { published } = await registerHandlers()
await handlers.get('automations:delete')?.(null, {
id: 'orphan-1',
expectedOwner: { selector: { kind: 'orphan' } }
})
expect(published).toEqual([{ reason: 'definition', selector: { kind: 'orphan' } }])
})
it('publishes source and destination when an update moves a record between hosts', async () => {
const { published, store } = await registerHandlers()
await handlers.get('automations:update')?.(null, {
id: 'local-1',
updates: { projectId: 'repo-ssh' },
expectedOwner: { selector: { kind: 'self' } },
destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }
})
expect(published).toEqual([
{ reason: 'definition', selector: { kind: 'self' } },
{ reason: 'definition', selector: { kind: 'ssh', targetId: 'ssh-1' } }
])
expect(store.automationChangeSelector('local-1')).toEqual({
kind: 'ssh',
targetId: 'ssh-1'
})
})
it('publishes one event when an update leaves the record on the same host', async () => {
const { published } = await registerHandlers()
await handlers.get('automations:update')?.(null, {
id: 'local-1',
updates: { enabled: false },
expectedOwner: { selector: { kind: 'self' } }
})
expect(published).toEqual([{ reason: 'definition', selector: { kind: 'self' } }])
})
})
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isExternalAutomationProbeCancelled } from '../automations/external-automation-probe-scheduler'
import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../shared/external-automation-scope'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type { SshTarget } from '../../shared/ssh-types'
import type { AutomationService } from '../automations/service'
import type { Store } from '../persistence'
type IpcHandler = (event: unknown, ...args: unknown[]) => unknown
const ipcHandlers = new Map<string, IpcHandler>()
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: IpcHandler) => {
ipcHandlers.set(channel, handler)
}
}
}))
// The relay is the only seam a scoped SSH probe crosses, so it stands in for the host.
const relay = {
request: vi.fn<(method: string, params: unknown) => Promise<unknown>>(),
isDisposed: () => false
}
vi.mock('../ipc/ssh', () => ({
getActiveMultiplexer: () => relay
}))
function sshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 't1',
label: 'build-box',
host: 'build.example',
user: 'orca',
generation: 3,
...overrides
} as SshTarget
}
function desktopSsh(targetId = 't1', targetGeneration = 3): AutomationOwnerRef {
return {
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId, targetGeneration }
}
}
const state: { targets: SshTarget[]; runNow: () => Promise<unknown> } = {
targets: [sshTarget()],
runNow: () => Promise.resolve({})
}
/** Async like the real bridge: Electron turns a handler's sync throw into a rejection. */
async function invoke(channel: string, payload?: unknown): Promise<unknown> {
const handler = ipcHandlers.get(channel)
if (!handler) {
throw new Error(`no handler registered for ${channel}`)
}
return await handler(null, payload)
}
beforeEach(async () => {
ipcHandlers.clear()
relay.request.mockReset()
relay.request.mockResolvedValue({ jobs: [], hermesAvailable: true, error: null })
state.targets = [sshTarget()]
state.runNow = () => Promise.resolve({})
const { registerAutomationHandlers } = await import('./automations')
registerAutomationHandlers(
{
getSshTargets: () => state.targets,
assertAutomationOwnerFence: () => undefined
} as unknown as Store,
{ runNow: () => state.runNow() } as unknown as AutomationService
)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('scoped external-manager IPC', () => {
it('derives the target from the captured owner rather than the payload', async () => {
const entry = (await invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'hermes'
})) as { manager: { id: string; target: unknown } | null; error: string | null }
expect(relay.request).toHaveBeenCalledWith('externalAutomations.list', { provider: 'hermes' })
expect(entry.manager?.id).toBe('hermes:ssh:t1')
expect(entry.manager?.target).toEqual({ type: 'ssh', connectionId: 't1' })
expect(entry.error).toBeNull()
})
it('refuses a request with no captured owner without probing', async () => {
await expect(
invoke('automations:listExternalManagerForOwner', { provider: 'hermes' })
).rejects.toThrow(/automation owner is required/i)
await expect(invoke('automations:runExternalActionForOwner', {})).rejects.toThrow(
/automation owner is required/i
)
expect(relay.request).not.toHaveBeenCalled()
})
it('refuses a runtime authority, so runtime hosts are never tunnelled', async () => {
await expect(
invoke('automations:listExternalManagerForOwner', {
owner: {
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 },
selector: { kind: 'self' }
},
provider: 'hermes'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
expect(relay.request).not.toHaveBeenCalled()
})
it('refuses a runtime-owned SSH target as hidden rather than as a probe failure', async () => {
state.targets = [sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'env-1' } })]
await expect(
invoke('automations:listExternalManagerForOwner', { owner: desktopSsh(), provider: 'hermes' })
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden)
expect(relay.request).not.toHaveBeenCalled()
})
it('refuses a stale host incarnation with the shared conflict vocabulary', async () => {
state.targets = [sshTarget({ generation: 4 })]
await expect(
invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh('t1', 3),
provider: 'hermes'
})
).rejects.toThrow(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
expect(relay.request).not.toHaveBeenCalled()
})
it('keeps one cache across requests, so a repeat read costs no second probe', async () => {
const request = { owner: desktopSsh(), provider: 'hermes' }
await invoke('automations:listExternalManagerForOwner', request)
await invoke('automations:listExternalManagerForOwner', request)
expect(relay.request).toHaveBeenCalledTimes(1)
await invoke('automations:listExternalManagerForOwner', { ...request, refresh: true })
expect(relay.request).toHaveBeenCalledTimes(2)
})
it('invalidates that host after a mutation through the scoped path', async () => {
const request = { owner: desktopSsh(), provider: 'hermes' }
await invoke('automations:listExternalManagerForOwner', request)
await invoke('automations:createExternalForOwner', {
...request,
name: 'Nightly',
prompt: 'sweep',
schedule: '0 9 * * *',
workdir: null
})
await invoke('automations:listExternalManagerForOwner', request)
// create + the re-probe after invalidation, on top of the first list.
expect(relay.request).toHaveBeenCalledTimes(3)
})
})
describe('probe scope retention', () => {
it('cancels an in-flight probe once its host leaves the retained set', async () => {
const probe: { release: () => void } = { release: () => undefined }
relay.request.mockImplementation(
() =>
new Promise((resolve) => {
probe.release = () => resolve({ jobs: [], hermesAvailable: true, error: null })
})
)
const pending = invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'hermes'
}) as Promise<unknown>
const settled = pending.catch((error: unknown) => error)
await Promise.resolve()
await invoke('automations:retainExternalScopes', { owners: [] })
const error = await settled
expect(isExternalAutomationProbeCancelled(error)).toBe(true)
probe.release()
})
it('keeps probing a host that is still retained', async () => {
await invoke('automations:retainExternalScopes', { owners: [desktopSsh()] })
const entry = (await invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'hermes'
})) as { manager: unknown }
expect(entry.manager).not.toBeNull()
})
})
describe('Orca automation traffic priority', () => {
it('parks queued probes while an Orca run dispatch holds the lease', async () => {
const dispatchControl: { finish: () => void } = { finish: () => undefined }
state.runNow = () =>
new Promise((resolve) => {
dispatchControl.finish = () => resolve({ id: 'run-1' })
})
const dispatch = invoke('automations:runNow', { id: 'a-1' }) as Promise<unknown>
await Promise.resolve()
const probe = invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'hermes'
}) as Promise<unknown>
await Promise.resolve()
// Why: the probe is queued, not started — this is the only thing keeping
// external discovery from competing with the work the user is waiting on.
expect(relay.request).not.toHaveBeenCalled()
dispatchControl.finish()
await dispatch
await probe
expect(relay.request).toHaveBeenCalledTimes(1)
})
})
+204 -37
View File
@@ -6,66 +6,233 @@ import type {
AutomationCreateInput,
AutomationDispatchResult,
AutomationPrecheckResult,
ExternalAutomationCreateInput,
ExternalAutomationActionInput,
ExternalAutomationManager,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput,
AutomationRun,
AutomationUpdateInput
} from '../../shared/automations-types'
import {
createExternalAutomation,
listExternalAutomationManagers,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
} from '../automations/external-manager'
automationChangePublications,
type AutomationChangeSelector,
type AutomationListParams,
type AutomationListResult
} from '../../shared/automation-list-scope'
import type {
AutomationDestination,
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import { createScopedExternalAutomations } from '../automations/external-manager'
import { runAutomationNowFenced } from '../automations/refused-manual-run'
import {
ExternalAutomationManagerCache,
type ExternalAutomationManagerCacheEntry
} from '../automations/external-automation-manager-cache'
import { ExternalAutomationProbeScheduler } from '../automations/external-automation-probe-scheduler'
import { ownerKey } from '../../shared/automation-owner-key'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
} from '../../shared/external-automation-scope'
/**
* Publishes the scoped `automationsChanged` events one definition change owes.
*
* A move touches two hosts, so both are named; an unknown side degrades the
* whole publication to one unscoped authority event rather than a partial one,
* because a subscriber that never hears about the host it is showing would keep
* a deleted row on screen.
*/
function publishDefinitionChange(
service: AutomationService,
before: AutomationChangeSelector | null,
after: AutomationChangeSelector | null
): void {
for (const selector of automationChangePublications(before, after)) {
service.publishAutomationsChanged({ reason: 'definition', ...(selector ? { selector } : {}) })
}
}
/**
* Fail closed: an external request with no captured owner is refused rather than
* resolved against whichever host happens to be active. Malformed input keeps
* the engine's plain-Error convention; it is not an ownership *conflict*.
*/
function requireCapturedOwner<T extends { owner?: AutomationOwnerRef | null }>(
request: T | null | undefined
): T {
const owner = request?.owner
if (!request || !owner || !owner.authority || !owner.selector) {
throw new Error('An automation owner is required for external automation requests.')
}
return request
}
/**
* Holds the probe pool's priority lease for the duration of Orca's own automation
* work. Without this, a queued external probe competes with the list and mutation
* traffic the user is actually waiting on.
*/
function underOrcaPriority<T>(scheduler: ExternalAutomationProbeScheduler, run: () => T): T {
const release = scheduler.beginPriorityWork()
let pending = false
try {
const result = run()
if (result instanceof Promise) {
pending = true
return result.finally(release) as T
}
return result
} finally {
if (!pending) {
release()
}
}
}
export function registerAutomationHandlers(store: Store, service: AutomationService): void {
ipcMain.handle('automations:list', (): Automation[] => store.listAutomations())
// One long-lived pair per registration: the cache TTL and the probe pool's
// concurrency ceiling only mean anything if they outlive a single request.
const probeScheduler = new ExternalAutomationProbeScheduler()
const managerCache = new ExternalAutomationManagerCache()
const scopedExternal = createScopedExternalAutomations({
// Hidden runtime-owned targets are passed through on purpose: the guard
// rejects them itself, so pre-filtering here would leak a different error.
registry: { getSshTargets: () => store.getSshTargets() },
scheduler: probeScheduler,
cache: managerCache
})
const withPriority = <T>(run: () => T): T => underOrcaPriority(probeScheduler, run)
// Why: an omitted selector keeps the legacy array so existing callers are untouched.
ipcMain.handle(
'automations:list',
(_event, params?: AutomationListParams | null): Automation[] | AutomationListResult =>
withPriority(() =>
params?.selector ? store.listAutomationsForScope(params) : store.listAutomations()
)
)
ipcMain.handle(
'automations:listRuns',
(_event, args?: { automationId?: string }): AutomationRun[] =>
store.listAutomationRuns(args?.automationId)
(
_event,
args?: { automationId?: string; expectedOwner?: AutomationOwnerPrecondition }
): AutomationRun[] =>
withPriority(() => {
if (args?.automationId && args.expectedOwner) {
store.assertAutomationOwnerFence({
id: args.automationId,
expectedOwner: args.expectedOwner,
operation: 'read'
})
}
return store.listAutomationRuns(args?.automationId)
})
)
ipcMain.handle('automations:listExternalManagers', (): Promise<ExternalAutomationManager[]> => {
return listExternalAutomationManagers(store)
})
// Scoped external-manager surface: one captured desktop owner in, one host's
// managers out. The target and manager ID are derived inside the guard.
ipcMain.handle(
'automations:listExternalRuns',
(_event, input: ExternalAutomationRunsInput): Promise<ExternalAutomationRunsPage> => {
return listExternalAutomationRuns(input)
}
'automations:listExternalManagerForOwner',
(
_event,
request: ScopedExternalManagerListRequest
): Promise<ExternalAutomationManagerCacheEntry> =>
scopedExternal.listManager(requireCapturedOwner(request))
)
ipcMain.handle('automations:createExternal', (_event, input: ExternalAutomationCreateInput) => {
return createExternalAutomation(input)
})
ipcMain.handle('automations:updateExternal', (_event, input: ExternalAutomationUpdateInput) => {
return updateExternalAutomation(input)
})
ipcMain.handle(
'automations:runExternalAction',
(_event, input: ExternalAutomationActionInput) => {
return runExternalAutomationAction(input)
'automations:listExternalRunsForOwner',
(_event, request: ScopedExternalManagerRunsRequest): Promise<ExternalAutomationRunsPage> =>
scopedExternal.listRuns(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:createExternalForOwner',
(_event, request: ScopedExternalManagerCreateRequest): Promise<void> =>
scopedExternal.create(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:updateExternalForOwner',
(_event, request: ScopedExternalManagerUpdateRequest): Promise<void> =>
scopedExternal.update(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:runExternalActionForOwner',
(_event, request: ScopedExternalManagerActionRequest): Promise<void> =>
scopedExternal.runAction(requireCapturedOwner(request))
)
// Why: probes for hosts the user is no longer viewing are cancelled, not left
// to finish. An empty list retains nothing, which is the correct fail-closed
// state for a renderer with no desktop scope selected.
ipcMain.handle(
'automations:retainExternalScopes',
(_event, request?: { owners?: readonly AutomationOwnerRef[] } | null): void => {
probeScheduler.retainScopes((request?.owners ?? []).map(ownerKey))
}
)
ipcMain.handle(
'automations:create',
(_event, input: AutomationCreateInput): Automation => store.createAutomation(input)
(
_event,
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
): Automation =>
withPriority(() => {
const created = store.createAutomation(input, options)
const selector = store.automationChangeSelector(created.id)
publishDefinitionChange(service, selector, selector)
return created
})
)
ipcMain.handle(
'automations:update',
(_event, args: { id: string; updates: AutomationUpdateInput }): Automation =>
store.updateAutomation(args.id, args.updates)
(
_event,
args: {
id: string
updates: AutomationUpdateInput
expectedOwner?: AutomationOwnerPrecondition
destination?: AutomationDestination
}
): Automation =>
withPriority(() => {
// Captured first: an update may move the record to another host.
const before = store.automationChangeSelector(args.id)
const updated = store.updateAutomation(args.id, args.updates, {
expectedOwner: args.expectedOwner,
destination: args.destination
})
publishDefinitionChange(service, before, store.automationChangeSelector(args.id))
return updated
})
)
ipcMain.handle(
'automations:delete',
(_event, args: { id: string; expectedOwner?: AutomationOwnerPrecondition }): void =>
withPriority(() => {
const before = store.automationChangeSelector(args.id)
store.deleteAutomation(args.id, { expectedOwner: args.expectedOwner })
publishDefinitionChange(service, before, before)
})
)
ipcMain.handle('automations:delete', (_event, args: { id: string }): void => {
store.deleteAutomation(args.id)
})
ipcMain.handle(
'automations:runNow',
(_event, args: { id: string }): Promise<AutomationRun> => service.runNow(args.id)
(
_event,
args: { id: string; expectedOwner?: AutomationOwnerPrecondition }
): Promise<AutomationRun> =>
withPriority(() =>
// Why: the dispatch is refused before a session exists, exactly like the runtime path.
runAutomationNowFenced({
fence: () =>
store.assertAutomationOwnerFence({
id: args.id,
expectedOwner: args.expectedOwner,
operation: 'execute'
}),
service,
automationId: args.id
})
)
)
ipcMain.handle(
'automations:runPrecheck',
@@ -0,0 +1,370 @@
/**
* SSH re-adoption has to repair the automation with the workspace.
*
* `reassignSshTargetId` used to re-point only repos, worktrees, sessions and
* setups, so a re-added host left every stored automation orphaned on the dead
* target id and the persisted Automations host filter naming a host that no
* longer existed. These tests drive the real Store through remove/re-add.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation, AutomationRun } from '../shared/automations-types'
import type { FolderWorkspace } from '../shared/folder-workspace-types'
import type { ProjectGroup } from '../shared/project-group-types'
import type { Repo } from '../shared/repo-types'
import type { RemovedSshTargetTombstone } from '../shared/ssh-types'
import type { SshConnectionStore } from './ssh/ssh-connection-store'
import { getDefaultPersistedState } from '../shared/constants'
import { toSshExecutionHostId } from '../shared/execution-host'
import { hostStableKey } from '../shared/automation-owner-key'
import { folderWorkspaceKey } from '../shared/workspace-scope'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const NOW = 1_700_000_000_000
const OLD_ID = 'ssh-1738000000000-a9f3x'
const IDENTITY = { host: 'dev.example.com', port: 22, username: 'tim' }
function desktopSshKey(targetId: string): string {
return hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } })
}
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'ssh',
executionTargetId: OLD_ID,
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW,
...overrides
}
}
function makeRun(): AutomationRun {
return {
id: 'run-1',
automationId: 'auto-1',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: toSshExecutionHostId(OLD_ID),
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/srv/repo'
},
title: 'Nightly #1',
scheduledFor: NOW,
status: 'completed',
trigger: 'scheduled',
workspaceId: null,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null,
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: NOW,
dispatchedAt: NOW,
createdAt: NOW
}
}
function sshRepo(): Repo {
return {
id: 'repo-1',
path: '/srv/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1,
connectionId: OLD_ID,
executionHostId: `ssh:${OLD_ID}`
} as Repo
}
function tombstone(overrides: Partial<RemovedSshTargetTombstone> = {}): RemovedSshTargetTombstone {
return {
oldTargetId: OLD_ID,
...IDENTITY,
configHost: 'dev.example.com',
label: 'Dev box',
removedAt: NOW,
...overrides
}
}
/** State as it looks right after the user removed the host: tombstone present, target gone. */
function removedHostState(overrides: Record<string, unknown> = {}) {
return {
repos: [sshRepo()],
sshTargets: [],
automations: [makeAutomation()],
removedSshTargetTombstones: [tombstone()],
ui: { ...getDefaultPersistedState(testState.dir).ui, automationHostFilter: undefined },
...overrides
}
}
async function createStoreFromState(state: Record<string, unknown>) {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
}
/** Re-read whatever is on disk now — no fixture rewrite. */
async function reloadStore() {
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
}
async function createSshStore(state: Record<string, unknown>) {
const store = await createStoreFromState(state)
const { SshConnectionStore } = await import('./ssh/ssh-connection-store')
return { store, ssh: new SshConnectionStore(store) }
}
/** Re-add the same host the tombstone remembers. */
function readdDevBox(ssh: SshConnectionStore) {
return ssh.addTarget({ label: 'Dev box', configHost: 'dev.example.com', ...IDENTITY })
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-readopt-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
vi.resetModules()
})
describe('SSH re-adoption migrates automations', () => {
it('leaves the automation orphaned while the host is gone', async () => {
const { store } = await createSshStore(removedHostState())
const [item] = store.listAutomationsForScope().items
expect(item.selector).toEqual({
kind: 'orphan',
issue: 'Its SSH host is no longer registered.'
})
})
it('re-points the owner onto the re-added registration so it resolves again', async () => {
const { store, ssh } = await createSshStore(removedHostState())
const added = readdDevBox(ssh)
const [item] = store.listAutomationsForScope().items
expect(item.selector).toEqual({
kind: 'ssh',
targetId: added.id,
targetGeneration: added.generation
})
expect(store.getRepo('repo-1')?.connectionId).toBe(added.id)
// The migration's orphan disable is not undone here: re-adoption repairs identity, not intent.
expect(store.listAutomations()[0].enabled).toBe(false)
})
it('persists the automation, repo and filter migrations together', async () => {
const { store, ssh } = await createSshStore(
removedHostState({
automationRuns: [makeRun()],
ui: {
...getDefaultPersistedState(testState.dir).ui,
automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) }
}
})
)
const added = readdDevBox(ssh)
store.flush()
const reloaded = await reloadStore()
const automation = reloaded.listAutomations()[0]
expect(automation.executionTargetId).toBe(added.id)
expect(automation.executionTargetGeneration).toBe(added.generation)
expect(reloaded.getUI().automationHostFilter).toEqual({
kind: 'host',
hostKey: desktopSshKey(added.id)
})
expect(reloaded.listAutomationRuns('auto-1')[0].runContext?.hostId).toBe(`ssh:${added.id}`)
expect(reloaded.getRepo('repo-1')?.connectionId).toBe(added.id)
})
it('repairs a folder workspace pinned to the removed host', async () => {
const folderWorkspace: FolderWorkspace = {
id: 'fw-1',
projectGroupId: 'group-1',
name: 'Remote folder',
folderPath: '/srv/folder',
connectionId: OLD_ID,
executionHostId: toSshExecutionHostId(OLD_ID),
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
createdAt: NOW,
updatedAt: NOW
}
const group: ProjectGroup = {
id: 'group-1',
name: 'Remote',
parentPath: '/srv/folder',
connectionId: OLD_ID,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
}
// A folder workspace on SSH whose repo row is local: only the workspace pin names the host.
const { store, ssh } = await createSshStore(
removedHostState({
repos: [{ ...sshRepo(), connectionId: undefined, executionHostId: 'local' } as Repo],
folderWorkspaces: [folderWorkspace],
projectGroups: [group],
automations: [
makeAutomation({
executionTargetType: 'local',
executionTargetId: 'local',
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1')
})
]
})
)
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'orphan',
issue: 'Its SSH host is no longer registered.'
})
const added = readdDevBox(ssh)
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'ssh',
targetId: added.id,
targetGeneration: added.generation
})
})
it('keeps allocating generations above every migrated capture', async () => {
const { store, ssh } = await createSshStore(
removedHostState({
automations: [makeAutomation({ executionTargetGeneration: 40 })],
sshTargetGenerationCounter: 40
})
)
const added = readdDevBox(ssh)
expect(added.generation).toBeGreaterThan(40)
expect(store.listAutomations()[0].executionTargetGeneration).toBe(added.generation)
// The migrated capture must never be able to depress or collide with a later allocation.
expect(store.allocateSshTargetGeneration()).toBeGreaterThan(added.generation!)
})
it('loads a pre-generation store and migrates it without dropping automations', async () => {
// No generations, no persisted filter, no counter: state written by an older build.
const { store, ssh } = await createSshStore({
repos: [sshRepo()],
sshTargets: [],
automations: [makeAutomation()],
removedSshTargetTombstones: [tombstone()],
ui: {}
})
const added = readdDevBox(ssh)
expect(store.listAutomations()).toHaveLength(1)
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'ssh',
targetId: added.id,
targetGeneration: added.generation
})
})
})
describe('SSH re-adoption tombstone retention', () => {
it('consumes the tombstone once nothing depends on its removal evidence', async () => {
const { store, ssh } = await createSshStore(
removedHostState({
ui: {
...getDefaultPersistedState(testState.dir).ui,
automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) }
}
})
)
readdDevBox(ssh)
expect(store.getRemovedSshTargetTombstones()).toEqual([])
})
it('retains the tombstone when a same-id re-registration leaves the automation on the old incarnation', async () => {
const { store } = await createSshStore(removedHostState())
const { readoptOrphanedWorkspacesForTarget } = await import('./ssh/ssh-target-readoption')
// Same id back with a new registration: nothing is re-pointed, so the automation
// still depends on the tombstone as the only evidence its host was replaced.
const target = { id: OLD_ID, label: 'Dev box', ...IDENTITY, generation: 9 }
store.addSshTarget(target)
expect(readoptOrphanedWorkspacesForTarget(store, target)).toEqual([])
expect(store.getRemovedSshTargetTombstones()).toHaveLength(1)
})
it('retains the tombstone while only the persisted filter still names the removed host', async () => {
const { store } = await createSshStore(
removedHostState({
automations: [],
ui: {
...getDefaultPersistedState(testState.dir).ui,
automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) }
}
})
)
store.releaseRemovedSshTargetTombstone(OLD_ID)
expect(store.getRemovedSshTargetTombstones()).toHaveLength(1)
})
})
@@ -0,0 +1,313 @@
/**
* A `local`-typed automation whose folder workspace pins it to an SSH host is
* projected as SSH-owned, so it must be fenceable like any other SSH-owned row.
*
* It used to capture no registration generation at all, so `captured ?? current`
* always read the *current* one and `targetReplaced` could never fire: deleting
* a host and re-adding one under the same id silently moved the schedule onto a
* different machine. These tests drive the real Store through create and reload.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Automation, AutomationCreateInput } from '../shared/automations-types'
import type { FolderWorkspace } from '../shared/folder-workspace-types'
import type { ProjectGroup } from '../shared/project-group-types'
import type { Repo } from '../shared/repo-types'
import type { SshTarget } from '../shared/ssh-types'
import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conflict'
import { getDefaultPersistedState } from '../shared/constants'
import { folderWorkspaceKey } from '../shared/workspace-scope'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const NOW = 1_700_000_000_000
const TARGET_ID = 'prod'
const FIRST_GENERATION = 4
const REPLACEMENT_GENERATION = 5
function prodTarget(generation: number): SshTarget {
return {
id: TARGET_ID,
label: 'Prod box',
host: 'prod.example.com',
port: 22,
username: 'tim',
generation
} as SshTarget
}
/** Local project outside the pinned folder scope: only the pin makes the record SSH-owned. */
function localRepo(): Repo {
return {
id: 'repo-1',
path: '/other/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
} as Repo
}
function projectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Group',
// A folder-backed group: folder workspaces only survive normalization under one.
parentPath: '/srv',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
} as ProjectGroup
}
/** A remote-rooted folder workspace: its scope connection is the pin. */
function pinnedFolderWorkspace(): FolderWorkspace {
return {
id: 'fw-1',
projectGroupId: 'group-1',
name: 'Pinned workspace',
folderPath: '/srv/remote',
connectionId: TARGET_ID,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
createdAt: NOW,
updatedAt: NOW
} as FolderWorkspace
}
function pinnedState(generation: number, overrides: Record<string, unknown> = {}) {
return {
repos: [localRepo()],
projectGroups: [projectGroup()],
folderWorkspaces: [pinnedFolderWorkspace()],
sshTargets: [prodTarget(generation)],
sshTargetGenerationCounter: generation,
automations: [],
...overrides
}
}
async function createStoreFromState(state: Record<string, unknown>) {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
}
async function reloadStore() {
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
}
/** The same target id now carries a different registration incarnation. */
function replaceStoredTargetGeneration(generation: number): void {
const file = join(testState.dir, 'orca-data.json')
const state = JSON.parse(readFileSync(file, 'utf-8'))
state.sshTargets = [prodTarget(generation)]
state.sshTargetGenerationCounter = generation
writeFileSync(file, JSON.stringify(state), 'utf-8')
}
const PINNED_CREATE_INPUT: AutomationCreateInput = {
name: 'Nightly',
prompt: 'go',
agentId: 'codex',
projectId: 'repo-1',
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1'),
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW
}
function createPinnedAutomation(store: {
createAutomation: (input: AutomationCreateInput) => Automation
}): Automation {
return store.createAutomation(PINNED_CREATE_INPUT)
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-pinned-fence-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
vi.resetModules()
})
describe('workspace-pinned automations capture a registration generation', () => {
it('stamps the pinned SSH target generation when the record is created', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
const automation = createPinnedAutomation(store)
expect(automation.executionTargetType).toBe('local')
expect(automation.executionTargetGeneration).toBe(FIRST_GENERATION)
})
it('projects the pinned record onto the SSH host while the registration is unchanged', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
createPinnedAutomation(store)
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'ssh',
targetId: TARGET_ID,
targetGeneration: FIRST_GENERATION
})
})
it('orphans the pinned record when the same target id is re-added as a new incarnation', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
createPinnedAutomation(store)
store.flush()
replaceStoredTargetGeneration(REPLACEMENT_GENERATION)
const reloaded = await reloadStore()
expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
})
it('refuses to dispatch the pinned record onto the replacement machine', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
createPinnedAutomation(store)
store.flush()
replaceStoredTargetGeneration(REPLACEMENT_GENERATION)
const reloaded = await reloadStore()
const automation = reloaded.listAutomations()[0]
expect(reloaded.automationCapturedHostIssue(automation)).toBe(
AUTOMATION_ORPHAN_ISSUES.targetReplaced
)
expect(() =>
reloaded.assertAutomationOwnerFence({ id: automation.id, operation: 'execute' })
).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }))
})
it('backfills a legacy pinned record that was stored without a capture', async () => {
const legacy = {
id: 'auto-legacy',
name: 'Legacy',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1'),
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW
}
const store = await createStoreFromState(
pinnedState(FIRST_GENERATION, { automations: [legacy] })
)
expect(store.listAutomations()[0].executionTargetGeneration).toBe(FIRST_GENERATION)
})
it('never re-stamps a differing capture, so a replacement cannot re-adopt the record', async () => {
const store = await createStoreFromState(
pinnedState(REPLACEMENT_GENERATION, {
automations: [
{
id: 'auto-captured',
name: 'Captured',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
executionTargetGeneration: FIRST_GENERATION,
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1'),
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW
}
]
})
)
expect(store.listAutomations()[0].executionTargetGeneration).toBe(FIRST_GENERATION)
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
})
})
describe('the destination a pinned create lands on', () => {
// The derived selection reads `local`; the record still lands on the pinned SSH host,
// so accepting Self here scheduled a job on a machine the user did not choose.
it('refuses a Self destination for a workspace-pinned record', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
expect(() =>
store.createAutomation(PINNED_CREATE_INPUT, { destination: { selector: { kind: 'self' } } })
).toThrowError(
expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination })
)
expect(store.listAutomations()).toHaveLength(0)
})
it('accepts the pinned host as the destination', async () => {
const store = await createStoreFromState(pinnedState(FIRST_GENERATION))
const created = store.createAutomation(PINNED_CREATE_INPUT, {
destination: {
selector: { kind: 'ssh', targetId: TARGET_ID, targetGeneration: FIRST_GENERATION }
}
})
expect(created.executionTargetGeneration).toBe(FIRST_GENERATION)
})
})
@@ -0,0 +1,212 @@
/**
* A folder workspace can be re-pointed at another SSH host outside the automation
* editor — the workspace's scope connection moves, and every record inside it
* moves with it. The capture on those records names the registration they were
* attached to, so it has to follow the pin or the record reads as a replaced
* orphan on a host that was never replaced.
*
* Following is gated on positive evidence: a live registration carrying the
* capture proves which pin it came from. Without that the capture stays put, so
* a host removed and re-added under the same id still cannot re-adopt the record.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { FolderWorkspace } from '../shared/folder-workspace-types'
import type { ProjectGroup } from '../shared/project-group-types'
import type { Repo } from '../shared/repo-types'
import type { SshTarget } from '../shared/ssh-types'
import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope'
import { getDefaultPersistedState } from '../shared/constants'
import { folderWorkspaceKey } from '../shared/workspace-scope'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const NOW = 1_700_000_000_000
const PROD_GENERATION = 4
const STAGING_GENERATION = 9
function target(id: string, generation: number): SshTarget {
return {
id,
label: id,
host: `${id}.example.com`,
port: 22,
username: 'tim',
generation
} as SshTarget
}
/** Local project outside the pinned folder scope: only the pin makes the record SSH-owned. */
function localRepo(): Repo {
return {
id: 'repo-1',
path: '/other/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
} as Repo
}
function projectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Group',
parentPath: '/srv',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
} as ProjectGroup
}
function folderWorkspace(connectionId: string): FolderWorkspace {
return {
id: 'fw-1',
projectGroupId: 'group-1',
name: 'Pinned workspace',
folderPath: '/srv/remote',
connectionId,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
createdAt: NOW,
updatedAt: NOW
} as FolderWorkspace
}
function pinnedAutomation() {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
executionTargetGeneration: PROD_GENERATION,
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: folderWorkspaceKey('fw-1'),
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: NOW,
enabled: true,
nextRunAt: NOW,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: NOW,
updatedAt: NOW
}
}
function initialState(): Record<string, unknown> {
return {
repos: [localRepo()],
projectGroups: [projectGroup()],
folderWorkspaces: [folderWorkspace('prod')],
sshTargets: [target('prod', PROD_GENERATION), target('staging', STAGING_GENERATION)],
sshTargetGenerationCounter: STAGING_GENERATION,
automations: [pinnedAutomation()]
}
}
async function loadStore(state: Record<string, unknown>) {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }),
'utf-8'
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
}
/** The workspace's execution host changes without any automation being edited. */
function repinWorkspace(connectionId: string, sshTargets?: SshTarget[]): void {
const file = join(testState.dir, 'orca-data.json')
const state = JSON.parse(readFileSync(file, 'utf-8'))
state.folderWorkspaces = [folderWorkspace(connectionId)]
if (sshTargets) {
state.sshTargets = sshTargets
}
writeFileSync(file, JSON.stringify(state), 'utf-8')
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-workspace-repin-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
vi.resetModules()
})
describe('a workspace re-pinned outside the automation editor', () => {
it('owns its records on the pin it started on', async () => {
const store = await loadStore(initialState())
expect(store.listAutomationsForScope().items[0].selector).toEqual({
kind: 'ssh',
targetId: 'prod',
targetGeneration: PROD_GENERATION
})
})
it('moves the captured owner onto the registration it now names', async () => {
const store = await loadStore(initialState())
store.flush()
repinWorkspace('staging')
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const reloaded = new Store()
expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(STAGING_GENERATION)
expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({
kind: 'ssh',
targetId: 'staging',
targetGeneration: STAGING_GENERATION
})
expect(reloaded.automationCapturedHostIssue(reloaded.listAutomations()[0])).toBeNull()
})
// No live registration carries the capture, so nothing proves it came from the old pin
// rather than from this one before it was replaced. The record stays fenced.
it('keeps the record fenced when the pin it left is gone too', async () => {
const store = await loadStore(initialState())
store.flush()
repinWorkspace('staging', [target('staging', STAGING_GENERATION)])
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const reloaded = new Store()
expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(PROD_GENERATION)
expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced
})
})
})
@@ -97,7 +97,9 @@ describe('OrcaRuntimeService automation methods', () => {
workspaceMode: 'new_per_run',
workspaceId: null,
setupDecision: 'skip'
})
}),
// Owner options are absent on an unfenced RPC create; the arg still rides.
undefined
)
expect(automation.id).toBe('auto-1')
})
@@ -109,14 +111,14 @@ describe('OrcaRuntimeService automation methods', () => {
const updated = await runtime.updateAutomation('auto-1', { enabled: false })
const removed = runtime.deleteAutomation('auto-1')
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }, undefined)
expect(updated).toMatchObject({
prompt: existingAutomation.prompt,
baseBranch: existingAutomation.baseBranch,
workspaceMode: existingAutomation.workspaceMode,
enabled: false
})
expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1')
expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined)
expect(removed).toEqual({ removed: true, id: 'auto-1' })
})
@@ -126,7 +128,7 @@ describe('OrcaRuntimeService automation methods', () => {
await runtime.updateAutomation('auto-1', { baseBranch: null })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }, undefined)
})
it('passes setup decision updates through the shared store', async () => {
@@ -135,7 +137,11 @@ describe('OrcaRuntimeService automation methods', () => {
await runtime.updateAutomation('auto-1', { setupDecision: 'run' })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { setupDecision: 'run' })
expect(store.updateAutomation).toHaveBeenCalledWith(
'auto-1',
{ setupDecision: 'run' },
undefined
)
})
it('passes session reuse updates for existing-workspace automations', async () => {
@@ -150,7 +156,7 @@ describe('OrcaRuntimeService automation methods', () => {
await runtime.updateAutomation('auto-1', { reuseSession: true })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { reuseSession: true })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { reuseSession: true }, undefined)
})
it('clears session reuse when retargeting to new-per-run', async () => {
@@ -176,7 +182,8 @@ describe('OrcaRuntimeService automation methods', () => {
workspaceMode: 'new_per_run',
workspaceId: null,
reuseSession: false
})
}),
undefined
)
})
+255 -199
View File
@@ -215,7 +215,6 @@ import type {
SkillUploadChunkRequest
} from '../../shared/skill-upload-session-contract'
import { SkillUploadSessionService } from '../skills/skill-upload-session-service'
import { SKILL_UPLOAD_STAGING_ROOT_NAME } from '../skills/skill-upload-staging-ownership'
import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract'
import {
installSkillBundleOnSshHost,
@@ -263,6 +262,17 @@ import type {
AutomationUpdateInput,
AutomationWorkspaceMode
} from '../../shared/automations-types'
import {
automationChangePublications,
type AutomationChangeSelector,
type AutomationListParams,
type AutomationListResult
} from '../../shared/automation-list-scope'
import type {
AutomationDestination,
AutomationOwnerFenceOperation,
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
import type { FolderWorkspace, WorkspaceKey } from '../../shared/folder-workspace-types'
import type {
@@ -371,13 +381,16 @@ import {
type ExecutionHostId
} from '../../shared/execution-host'
import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup'
import { getRegisteredSshState } from '../ssh/ssh-target-registry'
import { getRegisteredSshState } from '../ipc/ssh'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { ExactWorkerProviderSession } from '../../shared/orchestration-worker-output'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import type {
AutomationsChangedPayload,
RuntimeClientEvent
} from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import {
navigationTargetsClients,
@@ -570,10 +583,7 @@ import {
} from '../../shared/tui-agent-config'
import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
} from '../preflight/agent-detection'
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
import {
markCodexProjectTrusted,
markCopilotFolderTrusted,
@@ -616,7 +626,6 @@ import {
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree/visibility-sources'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
@@ -656,6 +665,7 @@ import {
} from '../ports/workspace-port-ownership'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import type { AutomationService } from '../automations/service'
import { runAutomationNowFenced } from '../automations/refused-manual-run'
import type { RuntimeBrowserCommands } from './orca-runtime-browser'
import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory'
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
@@ -731,13 +741,11 @@ import {
} from '../../shared/claude-agent-teams-tmux-compat'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import type { BrowserWindow } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { getRuntimeDesktopSurface } from './runtime-desktop-surface'
import { app, BrowserWindow, ipcMain, Notification } from 'electron'
import { RendererPublicationThrottle } from '../window/renderer-publication-throttle'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { BrowserError } from '../browser/browser-error'
import { BrowserError } from '../browser/cdp-bridge'
import {
getPRForBranch,
getPRForBranchOutcome,
@@ -913,7 +921,6 @@ import {
sanitizeLinearErrorMessage
} from '../linear/issue-context-errors'
import { listMcpIssues } from '../linear/mcp-issue-list'
import { linearPriorityLabel } from '../../shared/linear/priority-label'
import { writeIssueRelation } from '../linear/issue-relation-write'
import {
createProject as createLinearProject,
@@ -1136,7 +1143,14 @@ import {
} from '../worktree-removal-repo-owner'
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
import {
closeLocalWatcherForWorktreePath,
closeRemoteWatcherForWorktreePath,
forgetLocalWatcherRemovalSnapshot,
forgetRemoteWatcherRemovalSnapshot,
restoreLocalWatcherAfterFailedRemoval,
restoreRemoteWatcherAfterFailedRemoval
} from '../ipc/filesystem-watcher'
import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate'
import {
createWatcherRemovalDeadline,
@@ -1316,6 +1330,10 @@ type RuntimeStore = {
updateUI?: Store['updateUI']
recordFeatureInteraction?: Store['recordFeatureInteraction']
listAutomations?: Store['listAutomations']
listAutomationsForScope?: Store['listAutomationsForScope']
assertAutomationOwnerFence?: Store['assertAutomationOwnerFence']
automationOwnerPrecondition?: Store['automationOwnerPrecondition']
automationChangeSelector?: Store['automationChangeSelector']
listAutomationRuns?: Store['listAutomationRuns']
createAutomation?: Store['createAutomation']
updateAutomation?: Store['updateAutomation']
@@ -1383,6 +1401,7 @@ export type RuntimeAutomationCreateInput = Omit<
workspace?: string
workspaceMode?: AutomationWorkspaceMode
timezone?: string
destination?: AutomationDestination
}
export type RuntimeAutomationUpdateInput = Omit<
@@ -1563,6 +1582,7 @@ type TerminalCreateOptions = {
sessionId?: string
isNewSession?: boolean
preAllocatedHandle?: string
persistHostSessionBinding?: boolean
// Why: only the host-derived structured resume path may attach provider
// identity; opaque terminal.create commands remain ordinary shells.
agentSessionClaim?: AgentSessionExecutionClaim
@@ -2142,6 +2162,7 @@ type RuntimeNotifier = {
worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void
worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void
reposChanged(): void
automationsChanged?(payload: AutomationsChangedPayload): void
activateWorktree(
repoId: string,
worktreeId: string,
@@ -2987,16 +3008,6 @@ export type RuntimeRendererReloadFence = Readonly<{
recovery: 'renderer' | 'headless' | 'reloading'
}>
/** How a caller wants the provider-held screen fetched when the runtime has no
* bytes of its own. `visibleScreenOnly` narrows the result to the current grid:
* scrollback can still hold a ready banner a working agent printed minutes ago,
* which is history, not evidence of what the agent is doing now. */
type ProviderSnapshotReadOptions = {
timeoutMs?: number
retireOnTimeout?: boolean
visibleScreenOnly?: boolean
}
export class OrcaRuntimeService {
private readonly runtimeId = randomUUID()
private readonly startedAt = Date.now()
@@ -3671,7 +3682,6 @@ export class OrcaRuntimeService {
private skillCloudService: SkillCloudService | null = null
private agentSkillShareInProgress = false
private skillUploadSessions: SkillUploadSessionService | null = null
private skillUploadSessionsDisposed = false
private readonly skillTransactionRecovery: Promise<unknown>
private readonly skillInstallOperations = new Map<string, AbortController>()
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
@@ -3930,7 +3940,7 @@ export class OrcaRuntimeService {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
@@ -4056,18 +4066,55 @@ export class OrcaRuntimeService {
return this.store.listAutomations()
}
listAutomationRuns(automationId?: string): AutomationRun[] {
listAutomationsForScope(params: AutomationListParams): AutomationListResult {
if (!this.store?.listAutomationsForScope) {
throw new Error('runtime_unavailable')
}
return this.store.listAutomationsForScope(params)
}
// Why: a supplied precondition must never degrade to unfenced work, so a store that cannot check it fails the call.
private fenceAutomationOwner(
id: string,
expectedOwner: AutomationOwnerPrecondition | undefined,
operation: AutomationOwnerFenceOperation
): void {
if (!this.store?.assertAutomationOwnerFence) {
if (expectedOwner) {
throw new Error('runtime_unavailable')
}
return
}
this.store.assertAutomationOwnerFence({ id, expectedOwner, operation })
}
listAutomationRuns(
automationId?: string,
expectedOwner?: AutomationOwnerPrecondition
): AutomationRun[] {
if (!this.store?.listAutomationRuns) {
throw new Error('runtime_unavailable')
}
if (expectedOwner && !automationId) {
throw new Error('An expected owner requires an automation id.')
}
if (automationId) {
this.fenceAutomationOwner(automationId, expectedOwner, 'read')
}
return this.store.listAutomationRuns(automationId)
}
showAutomation(id: string): Automation {
/** Null when the store predates the projection: the caller then sends no precondition. */
automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null {
return this.store?.automationOwnerPrecondition?.(id) ?? null
}
showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation {
const automation = this.listAutomations().find((entry) => entry.id === id)
if (!automation) {
throw new Error('Automation not found.')
}
this.fenceAutomationOwner(id, expectedOwner, 'read')
return automation
}
@@ -4079,28 +4126,52 @@ export class OrcaRuntimeService {
if (input.reuseSession && target.workspaceMode !== 'existing') {
throw new Error('Session reuse requires an existing workspace target.')
}
return this.store.createAutomation({
name: input.name,
prompt: input.prompt,
precheck: input.precheck,
agentId: input.agentId,
runContext: input.runContext,
sourceContext: input.sourceContext,
projectId: target.projectId,
workspaceMode: target.workspaceMode,
workspaceId: target.workspaceId,
baseBranch: input.baseBranch,
setupDecision: input.setupDecision,
reuseSession: input.reuseSession,
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled,
missedRunGraceMinutes: input.missedRunGraceMinutes
})
const created = this.store.createAutomation(
{
name: input.name,
prompt: input.prompt,
precheck: input.precheck,
agentId: input.agentId,
runContext: input.runContext,
sourceContext: input.sourceContext,
projectId: target.projectId,
workspaceMode: target.workspaceMode,
workspaceId: target.workspaceId,
baseBranch: input.baseBranch,
setupDecision: input.setupDecision,
reuseSession: input.reuseSession,
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled,
missedRunGraceMinutes: input.missedRunGraceMinutes
},
input.destination ? { destination: input.destination } : undefined
)
const selector = this.automationChangeSelector(created.id)
this.publishAutomationDefinitionChange(selector, selector)
return created
}
async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise<Automation> {
private automationChangeSelector(id: string): AutomationChangeSelector | null {
return this.store?.automationChangeSelector?.(id) ?? null
}
/** A store that cannot name the affected host degrades to one unscoped authority event. */
private publishAutomationDefinitionChange(
before: AutomationChangeSelector | null,
after: AutomationChangeSelector | null
): void {
for (const selector of automationChangePublications(before, after)) {
this.notifyAutomationsChanged({ reason: 'definition', ...(selector ? { selector } : {}) })
}
}
async updateAutomation(
id: string,
updates: RuntimeAutomationUpdateInput,
options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination }
): Promise<Automation> {
if (!this.store?.updateAutomation) {
throw new Error('runtime_unavailable')
}
@@ -4167,23 +4238,41 @@ export class OrcaRuntimeService {
if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') {
throw new Error('Session reuse requires an existing workspace target.')
}
return this.store.updateAutomation(id, patch)
// Captured first: an update may move the record to another host.
const before = this.automationChangeSelector(id)
const updated = this.store.updateAutomation(id, patch, options)
this.publishAutomationDefinitionChange(before, this.automationChangeSelector(id))
return updated
}
deleteAutomation(id: string): { removed: boolean; id: string } {
deleteAutomation(
id: string,
expectedOwner?: AutomationOwnerPrecondition
): { removed: boolean; id: string } {
if (!this.store?.deleteAutomation) {
throw new Error('runtime_unavailable')
}
this.showAutomation(id)
this.store.deleteAutomation(id)
const before = this.automationChangeSelector(id)
this.store.deleteAutomation(id, expectedOwner ? { expectedOwner } : undefined)
this.publishAutomationDefinitionChange(before, before)
return { removed: true, id }
}
async runAutomationNow(id: string): Promise<AutomationRun> {
if (!this.automationService) {
async runAutomationNow(
id: string,
expectedOwner?: AutomationOwnerPrecondition
): Promise<AutomationRun> {
const service = this.automationService
if (!service) {
throw new Error('runtime_unavailable')
}
return await this.automationService.runNow(id)
// Why: an orphan or re-registered host must be refused before dispatch, not after a session starts.
return await runAutomationNowFenced({
fence: () => this.fenceAutomationOwner(id, expectedOwner, 'execute'),
service,
automationId: id
})
}
private async resolveAutomationTarget(
@@ -4238,12 +4327,13 @@ export class OrcaRuntimeService {
return { projectId, workspaceMode: 'new_per_run', workspaceId: null }
}
// Why: lazy initialization — the DB path depends on userData, which on the desktop
// is not finalized until after app.ready. Also allows unit tests to inject an
// in-memory DB without touching the filesystem.
// Why: lazy initialization — the DB path depends on Electron's userData
// which may not be finalized until after app.ready. Also allows unit tests
// to inject an in-memory DB without touching the filesystem.
getOrchestrationDb(): OrchestrationDb {
if (!this._orchestrationDb) {
const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db')
const { app } = require('electron')
const dbPath = join(app.getPath('userData'), 'orchestration.db')
this._orchestrationDb = new OrchestrationDb(dbPath)
this.ensureOrchestrationFederationRelay()
this.scheduleRestoredMessageRepoints()
@@ -5004,10 +5094,7 @@ export class OrcaRuntimeService {
signal?: AbortSignal
): Promise<AgentSkillShareOperation> {
const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors)
const operationRoot = join(
getAppEnvironment().getPath('userData'),
'agent-skill-share-operations'
)
const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations')
const cloud = this.requireSkillCloudService()
const preparations = new SkillSharePreparationService(
operationRoot,
@@ -5016,7 +5103,7 @@ export class OrcaRuntimeService {
createShare: (packageId, input) => cloud.createShare(packageId, input)
},
{
installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs')
installStateDirectory: join(app.getPath('userData'), 'skill-installs')
}
)
let preparationId: string | null = null
@@ -5223,7 +5310,7 @@ export class OrcaRuntimeService {
if (sshTarget) {
return installSkillBundleOnSshHost({
provider: sshTarget.provider,
userDataPath: getAppEnvironment().getPath('userData'),
userDataPath: app.getPath('userData'),
request: {
...request,
destination:
@@ -5232,14 +5319,14 @@ export class OrcaRuntimeService {
: request.destination
},
workspace: sshTarget.workspace,
requireHttps: getAppEnvironment().isPackaged(),
requireHttps: app.isPackaged,
signal: controller.signal,
onProgress: reportProgress
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
.map((origin) => origin.trim())
@@ -5248,9 +5335,9 @@ export class OrcaRuntimeService {
}
return await executeSkillBundleInstallRequest(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: getAppEnvironment().getPath('userData'),
stateDirectory: app.getPath('userData'),
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
requireHttps: getAppEnvironment().isPackaged(),
requireHttps: app.isPackaged,
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
@@ -5287,7 +5374,7 @@ export class OrcaRuntimeService {
if (sshTarget) {
return installSkillOnSshHost({
provider: sshTarget.provider,
userDataPath: getAppEnvironment().getPath('userData'),
userDataPath: app.getPath('userData'),
request: {
...request,
destination:
@@ -5296,13 +5383,13 @@ export class OrcaRuntimeService {
: request.destination
},
workspace: sshTarget.workspace,
requireHttps: getAppEnvironment().isPackaged(),
requireHttps: app.isPackaged,
signal
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
.map((origin) => origin.trim())
@@ -5311,9 +5398,9 @@ export class OrcaRuntimeService {
}
return executeSkillInstallRequest(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: getAppEnvironment().getPath('userData'),
stateDirectory: app.getPath('userData'),
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
requireHttps: getAppEnvironment().isPackaged(),
requireHttps: app.isPackaged,
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
@@ -5344,7 +5431,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
return previewSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: getAppEnvironment().getPath('userData'),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5372,7 +5459,7 @@ export class OrcaRuntimeService {
const runtimeId = this.getStatus().runtimeId
return previewSharedSkillBundleInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: getAppEnvironment().getPath('userData'),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5398,7 +5485,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
return removeSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: getAppEnvironment().getPath('userData'),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5417,7 +5504,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
const runtimeId = this.getStatus().runtimeId
const [installs, worktrees] = await Promise.all([
listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), {
listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), {
observeReceipt: async (receipt) => {
if (!receipt.wslDistro) {
return nativeSkillInstallFilesystem.observeSkill(
@@ -5709,26 +5796,12 @@ export class OrcaRuntimeService {
}
private requireSkillUploadSessions(): SkillUploadSessionService {
if (this.skillUploadSessionsDisposed) {
throw new Error('skill-upload-service-disposed')
}
this.skillUploadSessions ??= new SkillUploadSessionService(
join(
getAppEnvironment().getPath('userData'),
'skill-installs',
SKILL_UPLOAD_STAGING_ROOT_NAME
)
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
)
return this.skillUploadSessions
}
async disposeSkillUploadSessions(): Promise<void> {
this.skillUploadSessionsDisposed = true
const sessions = this.skillUploadSessions
this.skillUploadSessions = null
await sessions?.dispose()
}
getRuntimeId(): string {
return this.runtimeId
}
@@ -6459,6 +6532,14 @@ export class OrcaRuntimeService {
this.emitClientEvent({ type: 'reposChanged' })
}
// Why: automation writes land in the automation service and IPC handlers, so
// like SSH state they need a public entry point onto the client-event stream.
// Old clients drop the unknown event type; nothing is negotiated for it.
notifyAutomationsChanged(payload: AutomationsChangedPayload = {}): void {
this.notifier?.automationsChanged?.(payload)
this.emitClientEvent({ type: 'automationsChanged', ...payload })
}
// Why: SSH state changes originate in main's ssh handlers, not in runtime
// methods, so they need a public entry point onto the client-event stream.
notifySshStateChanged(targetId: string, state: SshConnectionState): void {
@@ -10237,11 +10318,11 @@ export class OrcaRuntimeService {
const results = await Promise.allSettled([
connectionId
? drainBeforeWatcherRemoval(
getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath),
closeRemoteWatcherForWorktreePath(connectionId, worktreePath),
deadline,
`remote watcher close for ${worktreePath}`
)
: getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline),
: closeLocalWatcherForWorktreePath(worktreePath, deadline),
drainBeforeWatcherRemoval(
this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId),
deadline,
@@ -10263,16 +10344,16 @@ export class OrcaRuntimeService {
): Promise<void> => {
await Promise.all([
connectionId
? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath)
: getWorktreeWatcherRemoval().restoreLocal(worktreePath),
? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath)
: restoreLocalWatcherAfterFailedRemoval(worktreePath),
this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId)
])
}
forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => {
if (connectionId) {
getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath)
forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath)
} else {
getWorktreeWatcherRemoval().forgetLocal(worktreePath)
forgetLocalWatcherRemovalSnapshot(worktreePath)
}
this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId)
}
@@ -13248,13 +13329,6 @@ export class OrcaRuntimeService {
const generation = this.getPtyLifecycleGeneration(ptyId)
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId)
// Why before the re-acquire branch: an unresponsive provider is a property of
// the process, not of the row count one caller asked for. Checking retirement
// only after re-acquiring let a wider request replace the retired entry and
// hang again; the hung call's own settle still clears it and allows recovery.
if (acquisition?.generation === generation && acquisition.timedOut) {
return null
}
if (
!acquisition ||
acquisition.generation !== generation ||
@@ -13359,7 +13433,7 @@ export class OrcaRuntimeService {
ptyId: string,
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {},
providerSnapshot: ProviderSnapshotReadOptions = {}
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<RuntimeTerminalRead> {
if (typeof opts.cursor === 'number') {
return read
@@ -13377,7 +13451,7 @@ export class OrcaRuntimeService {
const providerLines = await this.readProviderTerminalTailLines(
ptyId,
opts.limit,
providerSnapshot
providerWait
)
if (providerLines.length > 0) {
return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit)
@@ -13417,30 +13491,16 @@ export class OrcaRuntimeService {
private async readProviderTerminalTailLines(
ptyId: string,
limit: number | undefined,
snapshotOptions: ProviderSnapshotReadOptions = {}
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<string[]> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
const snapshot = await this.serializeProviderTerminalBuffer(
ptyId,
{ scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit },
snapshotOptions
{ scrollbackRows: lineLimit },
wait
)
if (!snapshot) {
return []
}
// Why: a cached acquisition can carry scrollback this caller did not ask for,
// so visible-only reads parse the grid itself rather than trusting the request.
if (snapshotOptions.visibleScreenOnly) {
const lines = await this.parseVisibleSnapshotLines(snapshot)
// Live bytes ordered after the provider frame make that frame stale.
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? lines
: []
}
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
if (data.length === 0) {
const data = snapshot ? `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` : ''
if (!snapshot || data.length === 0) {
return []
}
const emulator = new HeadlessEmulator({
@@ -13450,11 +13510,7 @@ export class OrcaRuntimeService {
})
try {
await emulator.write(data)
const lines = visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit))
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? lines
: []
return visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit))
} finally {
emulator.dispose()
}
@@ -13530,10 +13586,7 @@ export class OrcaRuntimeService {
}
}
const lines = await this.parseVisibleSnapshotLines(snapshot)
if (
this.getPtyLifecycleGeneration(ptyId) !== generation ||
this.getPtyOutputSequence(ptyId) > snapshot.seq
) {
if (this.getPtyLifecycleGeneration(ptyId) !== generation) {
return null
}
const visibleState: RuntimeVisibleTerminalState = {
@@ -13542,7 +13595,9 @@ export class OrcaRuntimeService {
sequence: snapshot.seq,
generation
}
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) {
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
}
return visibleState
}
@@ -14393,9 +14448,13 @@ export class OrcaRuntimeService {
const body = input.body ?? ''
let delivered = false
try {
delivered = getRuntimeDesktopSurface().showNotification({ title, body })
if (Notification.isSupported()) {
new Notification({ title, body }).show()
delivered = true
}
} catch {
// A host with no notification display still relays to paired clients below.
// Headless serve has no notification display; the mobile relay below
// still runs.
}
this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body })
return { delivered }
@@ -18438,7 +18497,9 @@ export class OrcaRuntimeService {
const recovery = this.createTerminal(`id:${expectedWorktreeId}`, {
tabId: parsed.tabId,
leafId: parsed.leafId,
focus: false
focus: false,
// Why: the HUB renderer may publish its exited layout while recovery is in flight; persist the replacement before that stale graph can orphan it.
persistHostSessionBinding: true
}).then((terminal) => ({
handle: terminal.handle,
tabId: parsed.tabId,
@@ -18499,14 +18560,14 @@ export class OrcaRuntimeService {
async readTerminal(
handle: string,
opts: { cursor?: number; limit?: number; screen?: boolean } = {},
providerSnapshot: ProviderSnapshotReadOptions = {}
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<RuntimeTerminalRead> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const read = this.readPtyTerminal(handle, pty.pty, opts)
const visibleRead = opts.screen
? await this.readRenderedScreen(pty.pty.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerWait)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -18528,7 +18589,7 @@ export class OrcaRuntimeService {
}
const visibleRead = opts.screen
? await this.readRenderedScreen(leaf.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerWait)
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -23373,8 +23434,7 @@ export class OrcaRuntimeService {
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(
@@ -23403,8 +23463,7 @@ export class OrcaRuntimeService {
}
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
@@ -23516,8 +23575,7 @@ export class OrcaRuntimeService {
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
])
)
@@ -27820,6 +27878,7 @@ export class OrcaRuntimeService {
presentation: request.presentation ?? 'background',
tabId: request.placement?.tabId,
leafId: request.placement?.leafId,
persistHostSessionBinding: true,
agentSessionClaim: claim,
signal: _caller.signal
})
@@ -28007,6 +28066,7 @@ export class OrcaRuntimeService {
leafId: operationLeafId,
preAllocatedHandle: operationHandle,
viewMode: request.viewMode,
persistHostSessionBinding: true,
agentSessionCreateOperationId: executionOperationId,
signal: caller.signal,
onPtySpawnCommitted: () => {
@@ -28228,6 +28288,10 @@ export class OrcaRuntimeService {
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const persistHostSessionBinding =
launchOpts.persistHostSessionBinding ||
launchOpts.surfaceOwner === false ||
this.getAvailableAuthoritativeWindow() === null
let result: Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
try {
result = await this.ptyController.spawn({
@@ -28276,10 +28340,12 @@ export class OrcaRuntimeService {
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
// Why: a host-initiated create has no renderer session writer, so
// without its own binding graph sync cannot classify the terminal
// and prunes the tab out from under a running agent.
persistHostSessionBinding: true
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
})
} finally {
releaseStablePaneCreate?.()
@@ -28319,9 +28385,9 @@ export class OrcaRuntimeService {
})
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
// Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs
// once the renderer de-persists the tab, i.e. when the user closes it.
pty.runtimeSessionOwned = true
if (persistHostSessionBinding) {
pty.runtimeSessionOwned = true
}
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
@@ -28427,7 +28493,7 @@ export class OrcaRuntimeService {
// creates the tab and replies with the tabId so we can resolve the handle.
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('Terminal creation timed out'))
}, 10_000)
@@ -28439,14 +28505,14 @@ export class OrcaRuntimeService {
return
}
clearTimeout(timer)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
ipcMain.removeListener('terminal:tabCreateReply', handler)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' })
}
}
getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
@@ -28798,7 +28864,7 @@ export class OrcaRuntimeService {
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
@@ -28806,7 +28872,7 @@ export class OrcaRuntimeService {
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
@@ -28818,7 +28884,7 @@ export class OrcaRuntimeService {
return
}
clearTimeout(timer)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
@@ -28827,7 +28893,7 @@ export class OrcaRuntimeService {
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
@@ -29067,6 +29133,7 @@ export class OrcaRuntimeService {
? { sessionId: stableSessionId }
: {}),
...(isNewSession ? { isNewSession: true } : {}),
persistHostSessionBinding: true,
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
deferMobileSessionPublish: true,
signal: opts.signal
@@ -34511,7 +34578,9 @@ export class OrcaRuntimeService {
return Date.now() - completedAtMs <= AGENT_STATUS_STALE_AFTER_MS ? dispatch : undefined
}
private getTerminalHandleForPaneKey(paneKey: string): string | null {
// Why: public because automation completion watching runs in main but the
// pane→handle mapping is runtime-owned state.
getTerminalHandleForPaneKey(paneKey: string): string | null {
const parsed = parsePaneKey(paneKey)
const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined
if (leaf?.ptyId && leaf.connected) {
@@ -35594,11 +35663,6 @@ export class OrcaRuntimeService {
}
}
/** One bounded look at the provider's screen for an adopted PTY whose retained
* readiness metadata was lost. Deliberately single-shot: it answers "is the
* screen already showing a settled prompt", and the poll above owns every
* later transition. A provider screen that is still working when this fires
* resolves through the poll, not here. */
private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void {
const snapshotTimeoutMs = Math.min(
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
@@ -35610,10 +35674,7 @@ export class OrcaRuntimeService {
{},
{
timeoutMs: snapshotTimeoutMs,
retireOnTimeout: true,
// Why: the ready banner stays in scrollback for the whole session, so
// classifying history would call a working agent idle (#15569 review).
visibleScreenOnly: true
retireOnTimeout: true
}
),
snapshotTimeoutMs,
@@ -35632,35 +35693,31 @@ export class OrcaRuntimeService {
if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) {
return
}
// Why resolve before clearing: a stale handle throws while locating the
// record, and a cleared interval would leave the waiter with no poll and
// no probe — able to end only in timeout.
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
this.resolveWaiter(waiter, result)
const pty = this.getLivePtyForHandle(waiter.handle)
if (pty) {
this.resolveWaiter(
waiter,
blockedReason
? buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty.pty)
)
return
}
const { leaf } = this.getLiveLeafForHandle(waiter.handle)
this.resolveWaiter(
waiter,
blockedReason
? buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)
)
})
.catch(() => {})
}
private buildTuiIdleProbeResult(
handle: string,
blockedReason: RuntimeTerminalWaitBlockedReason | null
): RuntimeTerminalWait {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
return blockedReason
? buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty)
}
const { leaf } = this.getLiveLeafForHandle(handle)
return blockedReason
? buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(handle, 'tui-idle', leaf)
}
private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId !== pty.ptyId) {
@@ -35885,16 +35942,14 @@ export class OrcaRuntimeService {
code: this.linearWorkspaceErrorCode(error.type),
message: sanitizeLinearErrorMessage(error.message)
}))
const hasMore = result.hasMore === true || result.items.length > limit
return {
projects,
truncated: hasMore,
meta: {
query: params.query,
workspaceId: params.workspaceId,
limit,
returned: projects.length,
hasMore,
hasMore: result.hasMore === true || result.items.length > limit,
partial: workspaceErrors.length > 0,
workspaceErrors
}
@@ -35934,13 +35989,11 @@ export class OrcaRuntimeService {
estimate: issue.estimate,
dueDate: issue.dueDate,
updatedAt: issue.updatedAt,
priorityLabel: linearPriorityLabel(issue.priority),
workspace: {
id: issue.workspaceId ?? workspaceId ?? '',
name: issue.workspaceName ?? issue.workspaceId ?? workspaceId ?? ''
}
})),
truncated: result.hasMore === true,
meta: {
filter,
workspaceId,
@@ -38621,7 +38674,10 @@ export class OrcaRuntimeService {
if (this.authoritativeWindowId === null) {
return null
}
const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId)
if (!BrowserWindow?.fromId) {
return null
}
const win = BrowserWindow.fromId(this.authoritativeWindowId)
return win && !win.isDestroyed() ? win : null
}
}
+22
View File
@@ -5,6 +5,10 @@ import {
ARTIFACT_SHARING_DISABLED_MESSAGE,
ArtifactSharingDisabledError
} from '../../../shared/artifact-sharing-gate'
import {
AUTOMATION_OWNER_CONFLICT_CODES,
AutomationOwnerConflictError
} from '../../../shared/automation-owner-conflict'
class LineageError extends Error {
code = 'LINEAGE_PARENT_NOT_FOUND'
@@ -228,3 +232,21 @@ describe('artifact sharing denial', () => {
})
})
})
// Why: the renderer decides between "reload the host", "re-adopt", and "stop offering
// the action" from this code; flattened to runtime_error it can only guess.
describe('automation owner conflicts', () => {
it.each(Object.values(AUTOMATION_OWNER_CONFLICT_CODES))(
'passes %s through structured',
(code) => {
expect(
mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new AutomationOwnerConflictError(code))
).toMatchObject({ ok: false, error: { code } })
}
)
it('still lets an old runtime be classified from the message tail', () => {
const error = new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
expect(error.message.endsWith(`: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}`)).toBe(true)
})
})
+5 -1
View File
@@ -21,6 +21,7 @@ import {
classifySkillInstallFailureCode
} from '../../../shared/skill-install-failure'
import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owner-conflict'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@@ -125,7 +126,10 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
AGENT_SKILL_SHARING_BUSY_CODE,
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
SKILL_INSTALL_RPC_ERROR_CODE
SKILL_INSTALL_RPC_ERROR_CODE,
// Why: an owner conflict is a distinct client decision (reload the host, re-adopt,
// stop offering the action) — flattened to runtime_error it can only be guessed at.
...Object.values(AUTOMATION_OWNER_CONFLICT_CODES)
])
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { OrcaRuntimeService } from '../../orca-runtime'
import { SettingsUpdate } from './client-ui-schemas'
import { SettingsUpdate } from './client-settings-schemas'
vi.mock('electron', () => ({
app: { getPath: () => '/orca-state', isPackaged: true }
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { OrcaRuntimeService } from '../../orca-runtime'
import { SettingsUpdate } from './client-ui-schemas'
import { SettingsUpdate } from './client-settings-schemas'
function runtimeWithSharing(artifactSharingEnabled: unknown): OrcaRuntimeService {
return new OrcaRuntimeService({
@@ -0,0 +1,190 @@
// Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it.
import { z } from 'zod'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedules'
import {
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
normalizeAutomationPrecheckTimeoutSeconds
} from '../../../../shared/automation-precheck'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import {
OptionalBoolean,
OptionalPlainString,
OptionalPositiveInt,
OptionalString,
requiredNumber,
requiredString
} from '../schemas'
const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
message: 'Unknown provider'
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional()
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
return z.NEVER
}
return hostId
})
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
message: 'Invalid automation trigger'
})
const AutomationPrecheck = z
.object({
command: requiredString('Missing precheck command'),
timeoutSeconds: OptionalPositiveInt.transform((value) =>
normalizeAutomationPrecheckTimeoutSeconds(value)
).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, {
message: 'Precheck timeout is too large'
})
})
.nullable()
.optional()
const OptionalNullablePlainString = z
.unknown()
.transform((value) => (value === null || typeof value === 'string' ? value : undefined))
.pipe(z.union([z.string(), z.null(), z.undefined()]))
.optional()
const TaskProviderIdentity = z
.custom<SharedTaskProviderIdentity>(
(value) =>
value !== null &&
typeof value === 'object' &&
'provider' in value &&
['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
)
.optional()
.nullable()
const TaskSourceContext = z
.object({
kind: z.literal('task-source'),
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
projectId: requiredString('Missing source project id'),
hostId: ExecutionHostId,
projectHostSetupId: OptionalNullablePlainString,
repoId: OptionalNullablePlainString,
providerIdentity: TaskProviderIdentity,
accountLabel: OptionalNullablePlainString
})
.optional()
.nullable()
const WorkspaceRunContext = z
.object({
kind: z.literal('workspace-run'),
projectId: requiredString('Missing run project id'),
hostId: ExecutionHostId,
projectHostSetupId: requiredString('Missing project host setup id'),
repoId: requiredString('Missing repo id'),
path: requiredString('Missing run path')
})
.optional()
.nullable()
const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine(
(value) => Number.isSafeInteger(value) && value >= 1,
{ message: 'Invalid SSH target generation' }
)
const OwnedSshSelector = z.object({
kind: z.literal('ssh'),
targetId: requiredString('Missing SSH target id'),
targetGeneration: SshTargetGeneration
})
/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */
const OwnerPreconditionSelector = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('self') }),
OwnedSshSelector,
z.object({ kind: z.literal('orphan') })
])
const DestinationSelector = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('self') }),
OwnedSshSelector
])
export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional()
export const Destination = z.object({ selector: DestinationSelector }).optional()
const ListScopeSelector = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('self') }),
z.object({
kind: z.literal('ssh'),
targetId: requiredString('Missing SSH target id'),
expectedTargetGeneration: SshTargetGeneration
}),
z.object({ kind: z.literal('orphan') })
])
/** An omitted selector is the legacy request; old clients keep the authority's complete list. */
export const AutomationList = z.object({ selector: ListScopeSelector.optional() })
export const AutomationId = z.object({
id: requiredString('Missing automation id'),
expectedOwner: ExpectedOwner
})
export const AutomationRuns = z.object({
automationId: OptionalString,
expectedOwner: ExpectedOwner
})
export const AutomationCreate = z.object({
name: requiredString('Missing automation name'),
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
agentId: TuiAgent,
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
baseBranch: OptionalPlainString,
setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule,
dtstart: requiredNumber('Missing trigger start time'),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt,
destination: Destination
})
const AutomationUpdateFields = z.object({
name: OptionalString,
prompt: OptionalString,
precheck: AutomationPrecheck,
agentId: TuiAgent.optional(),
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
// Why: update patches distinguish omitted from null so callers can clear a saved base branch.
baseBranch: OptionalNullablePlainString,
setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule.optional(),
dtstart: requiredNumber('Missing trigger start time').optional(),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt
})
export const AutomationUpdate = z.object({
id: requiredString('Missing automation id'),
updates: AutomationUpdateFields,
expectedOwner: ExpectedOwner,
destination: Destination
})
@@ -0,0 +1,196 @@
/**
* The runtime end of the list contract: an old client that sends no params must
* keep receiving the authority's complete list in the legacy shape, and every
* owner precondition must survive schema parsing rather than being stripped.
*/
import { describe, expect, it, vi } from 'vitest'
import type { RpcContext, RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { AUTOMATION_METHODS } from './automations'
function method(name: string) {
const found = AUTOMATION_METHODS.find((entry) => entry.name === name)
if (!found?.params) {
throw new Error(`missing method ${name}`)
}
return found
}
function runtimeStub() {
return {
listAutomations: vi.fn(() => [{ id: 'a1' }, { id: 'a2' }]),
listAutomationsForScope: vi.fn(() => ({
automations: [{ id: 'a1' }],
items: [{ automationId: 'a1', selector: { kind: 'self' } }],
orphanCount: 2
})),
listAutomationRuns: vi.fn(() => []),
showAutomation: vi.fn(() => ({ id: 'a1' })),
automationOwnerPrecondition: vi.fn(() => SSH_OWNER),
updateAutomation: vi.fn(async () => ({ id: 'a1' })),
deleteAutomation: vi.fn(() => ({ removed: true, id: 'a1' })),
runAutomationNow: vi.fn(async () => ({ id: 'run-1' }))
}
}
async function invoke(name: string, params: unknown, runtime: ReturnType<typeof runtimeStub>) {
const target = method(name)
// Why: mirrors the dispatcher, which turns omitted params into an empty object before parsing.
const parsed = target.params?.safeParse(params ?? {})
if (!parsed?.success) {
throw parsed?.error
}
return await target.handler(parsed.data, {
runtime: runtime as unknown as OrcaRuntimeService
} as RpcContext)
}
const SSH_OWNER = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }
describe('automation.list', () => {
it('answers a parameterless request with the complete legacy payload', async () => {
const runtime = runtimeStub()
expect(await invoke('automation.list', undefined, runtime)).toEqual({
automations: [{ id: 'a1' }, { id: 'a2' }]
})
expect(runtime.listAutomationsForScope).not.toHaveBeenCalled()
})
it('treats an empty object the same as no params', async () => {
const runtime = runtimeStub()
expect(await invoke('automation.list', {}, runtime)).toEqual({
automations: [{ id: 'a1' }, { id: 'a2' }]
})
})
it('answers a scoped request with items and the orphan count', async () => {
const runtime = runtimeStub()
const result = await invoke('automation.list', { selector: { kind: 'self' } }, runtime)
expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({ selector: { kind: 'self' } })
expect(result).toMatchObject({ orphanCount: 2, items: [{ automationId: 'a1' }] })
})
it('requires an SSH scope to name the generation it expects', async () => {
const runtime = runtimeStub()
await expect(
invoke('automation.list', { selector: { kind: 'ssh', targetId: 'ssh-1' } }, runtime)
).rejects.toBeTruthy()
})
})
/**
* The other two cases go through `invoke`, which normalizes params itself — so
* literal `null` has to be proved through the real dispatcher instead. An old
* client that serializes an absent params field as `null` is the case the
* legacy fallback rests on, and only the dispatcher decides what it becomes.
*/
describe('automation.list from a client that sends literal null params', () => {
it('answers with the complete authority list, not an invalid-argument error', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: { ...runtime, getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService,
methods: AUTOMATION_METHODS
})
const request: RpcRequest = {
id: 'req-1',
authToken: 'tok',
method: 'automation.list',
params: null
}
const response = await dispatcher.dispatch(request)
expect(response).toMatchObject({ result: { automations: [{ id: 'a1' }, { id: 'a2' }] } })
expect(runtime.listAutomationsForScope).not.toHaveBeenCalled()
})
})
describe('owner preconditions', () => {
it('forwards the expected owner on show, runs, delete, and runNow', async () => {
const runtime = runtimeStub()
await invoke('automation.show', { id: 'a1', expectedOwner: SSH_OWNER }, runtime)
await invoke('automation.runs', { automationId: 'a1', expectedOwner: SSH_OWNER }, runtime)
await invoke('automation.delete', { id: 'a1', expectedOwner: SSH_OWNER }, runtime)
await invoke('automation.runNow', { id: 'a1', expectedOwner: SSH_OWNER }, runtime)
expect(runtime.showAutomation).toHaveBeenCalledWith('a1', SSH_OWNER)
expect(runtime.listAutomationRuns).toHaveBeenCalledWith('a1', SSH_OWNER)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', SSH_OWNER)
expect(runtime.runAutomationNow).toHaveBeenCalledWith('a1', SSH_OWNER)
})
// Why: a client with no SSH target registry of its own — the CLI — can only satisfy
// the fence by echoing back an owner the authority projected for it.
it('returns the projected owner beside the automation on show', async () => {
const runtime = runtimeStub()
expect(await invoke('automation.show', { id: 'a1' }, runtime)).toEqual({
automation: { id: 'a1' },
owner: SSH_OWNER
})
})
it('omits the owner rather than inventing one when the store cannot project it', async () => {
const runtime = runtimeStub()
runtime.automationOwnerPrecondition.mockReturnValue(null as never)
expect(await invoke('automation.show', { id: 'a1' }, runtime)).toEqual({
automation: { id: 'a1' }
})
})
it('forwards both sides of a selector-moving update', async () => {
const runtime = runtimeStub()
await invoke(
'automation.update',
{
id: 'a1',
updates: { enabled: false },
expectedOwner: SSH_OWNER,
destination: { selector: { kind: 'self' } }
},
runtime
)
expect(runtime.updateAutomation).toHaveBeenCalledWith(
'a1',
expect.objectContaining({ enabled: false }),
{ expectedOwner: SSH_OWNER, destination: { selector: { kind: 'self' } } }
)
})
it('accepts an orphan precondition but never an orphan destination', async () => {
const runtime = runtimeStub()
await invoke(
'automation.delete',
{ id: 'a1', expectedOwner: { selector: { kind: 'orphan' } } },
runtime
)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', {
selector: { kind: 'orphan' }
})
await expect(
invoke(
'automation.update',
{ id: 'a1', updates: {}, destination: { selector: { kind: 'orphan' } } },
runtime
)
).rejects.toBeTruthy()
})
it('rejects an SSH precondition with no registration generation', async () => {
const runtime = runtimeStub()
await expect(
invoke(
'automation.delete',
{ id: 'a1', expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1' } } },
runtime
)
).rejects.toBeTruthy()
})
it('leaves the parameterless old-client shapes valid', async () => {
const runtime = runtimeStub()
await invoke('automation.delete', { id: 'a1' }, runtime)
await invoke('automation.runs', {}, runtime)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', undefined)
expect(runtime.listAutomationRuns).toHaveBeenCalledWith(undefined, undefined)
})
})
@@ -71,7 +71,8 @@ describe('automation RPC methods', () => {
await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' }))
expect(runtime.listAutomations).toHaveBeenCalled()
expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1')
// A legacy client sends no precondition, so each call forwards an absent expected owner.
expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1', undefined)
expect(runtime.createAutomation).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New review',
@@ -92,11 +93,12 @@ describe('automation RPC methods', () => {
setupDecision: 'run',
reuseSession: false,
rrule: '0 9 * * 1-5'
})
}),
{ expectedOwner: undefined, destination: undefined }
)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1')
expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1')
expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1')
expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined)
expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1', undefined)
expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1', undefined)
})
it('rejects unknown providers and invalid schedules', async () => {
@@ -147,6 +149,10 @@ describe('automation RPC methods', () => {
})
)
expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null })
expect(runtime.updateAutomation).toHaveBeenCalledWith(
'auto-1',
{ baseBranch: null },
{ expectedOwner: undefined, destination: undefined }
)
})
})
+29 -151
View File
@@ -1,160 +1,33 @@
import { z } from 'zod'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedules'
import {
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
normalizeAutomationPrecheckTimeoutSeconds
} from '../../../../shared/automation-precheck'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { defineMethod, type RpcMethod } from '../core'
import {
OptionalBoolean,
OptionalPlainString,
OptionalPositiveInt,
OptionalString,
requiredNumber,
requiredString
} from '../schemas'
const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
message: 'Unknown provider'
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional()
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
return z.NEVER
}
return hostId
})
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
message: 'Invalid automation trigger'
})
const AutomationPrecheck = z
.object({
command: requiredString('Missing precheck command'),
timeoutSeconds: OptionalPositiveInt.transform((value) =>
normalizeAutomationPrecheckTimeoutSeconds(value)
).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, {
message: 'Precheck timeout is too large'
})
})
.nullable()
.optional()
const OptionalNullablePlainString = z
.unknown()
.transform((value) => (value === null || typeof value === 'string' ? value : undefined))
.pipe(z.union([z.string(), z.null(), z.undefined()]))
.optional()
const TaskProviderIdentity = z
.custom<SharedTaskProviderIdentity>(
(value) =>
value !== null &&
typeof value === 'object' &&
'provider' in value &&
['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
)
.optional()
.nullable()
const TaskSourceContext = z
.object({
kind: z.literal('task-source'),
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
projectId: requiredString('Missing source project id'),
hostId: ExecutionHostId,
projectHostSetupId: OptionalNullablePlainString,
repoId: OptionalNullablePlainString,
providerIdentity: TaskProviderIdentity,
accountLabel: OptionalNullablePlainString
})
.optional()
.nullable()
const WorkspaceRunContext = z
.object({
kind: z.literal('workspace-run'),
projectId: requiredString('Missing run project id'),
hostId: ExecutionHostId,
projectHostSetupId: requiredString('Missing project host setup id'),
repoId: requiredString('Missing repo id'),
path: requiredString('Missing run path')
})
.optional()
.nullable()
const AutomationId = z.object({
id: requiredString('Missing automation id')
})
const AutomationRuns = z.object({
automationId: OptionalString
})
const AutomationCreate = z.object({
name: requiredString('Missing automation name'),
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
agentId: TuiAgent,
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
baseBranch: OptionalPlainString,
setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule,
dtstart: requiredNumber('Missing trigger start time'),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt
})
const AutomationUpdateFields = z.object({
name: OptionalString,
prompt: OptionalString,
precheck: AutomationPrecheck,
agentId: TuiAgent.optional(),
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
// Why: update patches distinguish omitted from null so callers can clear a saved base branch.
baseBranch: OptionalNullablePlainString,
setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule.optional(),
dtstart: requiredNumber('Missing trigger start time').optional(),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt
})
const AutomationUpdate = z.object({
id: requiredString('Missing automation id'),
updates: AutomationUpdateFields
})
AutomationCreate,
AutomationId,
AutomationList,
AutomationRuns,
AutomationUpdate
} from './automation-schemas'
export const AUTOMATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'automation.list',
params: null,
handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() })
params: AutomationList,
// Why: a scoped caller gets the qualified projection; a parameterless one keeps the legacy payload.
handler: (params, { runtime }) =>
params.selector
? runtime.listAutomationsForScope({ selector: params.selector })
: { automations: runtime.listAutomations() }
}),
defineMethod({
name: 'automation.show',
params: AutomationId,
handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) })
// Why: the owner rides along so a client that cannot project one itself — the
// CLI — can echo it back on the mutation that follows. Optional: an older
// host omits it, and an older client ignores it.
handler: (params, { runtime }) => {
const automation = runtime.showAutomation(params.id, params.expectedOwner)
const owner = runtime.automationOwnerPrecondition(params.id)
return owner ? { automation, owner } : { automation }
}
}),
defineMethod({
name: 'automation.create',
@@ -167,24 +40,29 @@ export const AUTOMATION_METHODS: RpcMethod[] = [
name: 'automation.update',
params: AutomationUpdate,
handler: async (params, { runtime }) => ({
automation: await runtime.updateAutomation(params.id, params.updates)
automation: await runtime.updateAutomation(params.id, params.updates, {
expectedOwner: params.expectedOwner,
destination: params.destination
})
})
}),
defineMethod({
name: 'automation.delete',
params: AutomationId,
handler: (params, { runtime }) => runtime.deleteAutomation(params.id)
handler: (params, { runtime }) => runtime.deleteAutomation(params.id, params.expectedOwner)
}),
defineMethod({
name: 'automation.runNow',
params: AutomationId,
handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) })
handler: async (params, { runtime }) => ({
run: await runtime.runAutomationNow(params.id, params.expectedOwner)
})
}),
defineMethod({
name: 'automation.runs',
params: AutomationRuns,
handler: (params, { runtime }) => ({
runs: runtime.listAutomationRuns(params.automationId)
runs: runtime.listAutomationRuns(params.automationId, params.expectedOwner)
})
})
]
@@ -0,0 +1,81 @@
import { z } from 'zod'
import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides'
import { isTaskProvider, type TaskProvider } from '../../../../shared/task-providers'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../../shared/tui-agent-launch-defaults'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema'
const TaskProviderParam = z.custom<TaskProvider>(isTaskProvider, {
message: 'Unknown task provider'
})
export const PRBotAuthorOverrideUpdate = z
.object({ author: z.string(), isBot: z.boolean() })
.strict()
const GitHubProjectRef = z
.object({
owner: z.string(),
ownerType: z.enum(['organization', 'user']),
number: z.number().int(),
host: z.string().optional()
})
.strict()
const GitHubProjectSettings = z
.object({
pinned: z.array(GitHubProjectRef),
recent: z.array(
GitHubProjectRef.extend({
lastOpenedAt: z.string()
}).strict()
),
lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
activeProject: GitHubProjectRef.nullable()
})
.strict()
export const SettingsUpdate = z
.object({
worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(),
defaultTuiAgent: z
.unknown()
.transform((value) =>
value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
)
.optional(),
disabledTuiAgents: z
.unknown()
.transform((value) => normalizeDisabledTuiAgents(value))
.optional(),
agentDefaultArgs: z
.unknown()
.transform((value) => normalizeTuiAgentArgsRecord(value))
.optional(),
agentDefaultEnv: z
.unknown()
.transform((value) => normalizeTuiAgentEnvRecord(value))
.optional(),
defaultTaskSource: TaskProviderParam.optional(),
visibleTaskProviders: z.array(TaskProviderParam).optional(),
defaultTaskViewPreset: z
.enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
.optional(),
experimentalNewWorktreeCardStyle: z.boolean().optional(),
agentStatusHooksEnabled: z.boolean().optional(),
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
compactWorktreeCards: z.boolean().optional(),
minimaxGroupId: z.string().optional(),
minimaxUsageModels: z.string().optional(),
githubProjects: GitHubProjectSettings.optional(),
prBotAuthorOverrides: z
.unknown()
.transform((value) => normalizePRBotAuthorOverrides(value))
.optional()
})
.strict()
.default({})
@@ -4,32 +4,19 @@ import {
type FeatureInteractionId
} from '../../../../shared/feature-interactions'
import { isFeatureTipId } from '../../../../shared/feature-tips'
import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../../shared/tui-agent-launch-defaults'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTaskProvider } from '../../../../shared/task-providers'
import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides'
import {
normalizeWorktreeCardProperties,
WORKTREE_CARD_PROPERTIES
} from '../../../../shared/worktree/card-properties'
import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest'
import type { TaskProvider } from '../../../../shared/task-providers'
import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields'
import { TaskResumeState } from './task-resume-state-schema'
import { WorkspaceCleanup } from './workspace-cleanup-ui-schema'
import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance'
import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema'
const NullableString = z.string().nullable()
const StringArray = z.array(z.string())
const TaskProviderParam = z.custom<TaskProvider>(isTaskProvider, {
message: 'Unknown task provider'
})
const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' }))
const UnknownRecord = z.record(z.string(), z.unknown())
const UnknownRecordArray = z.array(UnknownRecord)
@@ -101,72 +88,6 @@ const FeatureInteractions = z
export const FeatureInteractionIdParam = z.custom<FeatureInteractionId>(isFeatureInteractionId, {
message: 'Unknown feature interaction id'
})
export const PRBotAuthorOverrideUpdate = z
.object({ author: z.string(), isBot: z.boolean() })
.strict()
const GitHubProjectRef = z
.object({
owner: z.string(),
ownerType: z.enum(['organization', 'user']),
number: z.number().int(),
host: z.string().optional()
})
.strict()
const GitHubProjectSettings = z
.object({
pinned: z.array(GitHubProjectRef),
recent: z.array(
GitHubProjectRef.extend({
lastOpenedAt: z.string()
}).strict()
),
lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
activeProject: GitHubProjectRef.nullable()
})
.strict()
export const SettingsUpdate = z
.object({
worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(),
defaultTuiAgent: z
.unknown()
.transform((value) =>
value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
)
.optional(),
disabledTuiAgents: z
.unknown()
.transform((value) => normalizeDisabledTuiAgents(value))
.optional(),
agentDefaultArgs: z
.unknown()
.transform((value) => normalizeTuiAgentArgsRecord(value))
.optional(),
agentDefaultEnv: z
.unknown()
.transform((value) => normalizeTuiAgentEnvRecord(value))
.optional(),
defaultTaskSource: TaskProviderParam.optional(),
visibleTaskProviders: z.array(TaskProviderParam).optional(),
defaultTaskViewPreset: z
.enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
.optional(),
experimentalNewWorktreeCardStyle: z.boolean().optional(),
agentStatusHooksEnabled: z.boolean().optional(),
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
compactWorktreeCards: z.boolean().optional(),
minimaxGroupId: z.string().optional(),
minimaxUsageModels: z.string().optional(),
githubProjects: GitHubProjectSettings.optional(),
prBotAuthorOverrides: z
.unknown()
.transform((value) => normalizePRBotAuthorOverrides(value))
.optional()
})
.strict()
.default({})
const TopLevelViewSchema = z.enum([
'terminal',
'settings',
@@ -202,6 +123,12 @@ const UiUpdateFields = z
workspaceHostScope: z.string().optional(),
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
automationHostFilter: z
.union([
z.object({ kind: z.literal('all') }).strict(),
z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict()
])
.optional(),
manualRepoOrder: z
.array(z.object({ hostId: z.string(), repoId: z.string() }).strict())
.optional(),
+2 -6
View File
@@ -1,12 +1,8 @@
import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import { defineMethod, type RpcMethod } from '../core'
import {
FeatureInteractionIdParam,
PRBotAuthorOverrideUpdate,
SettingsUpdate,
UiUpdate
} from './client-ui-schemas'
import { PRBotAuthorOverrideUpdate, SettingsUpdate } from './client-settings-schemas'
import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas'
// Type-only side effect: keeps the schema/PersistedUIState parity assertions in
// the typecheck graph so drift fails the build instead of a paired client.
+8 -2
View File
@@ -7,13 +7,19 @@ import {
} from '../../../ssh/ssh-target-registry'
import { defineMethod, type RpcMethod } from '../core'
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
import type { SshTargetSummary } from '../../../../shared/ssh-types'
const SshTarget = z.object({
targetId: z.string().min(1)
})
function listRegisteredSshTargetSummaries(): { id: string; label: string }[] {
return listRegisteredSshTargets().map(({ id, label }) => ({ id, label }))
// Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone.
function listRegisteredSshTargetSummaries(): SshTargetSummary[] {
return listRegisteredSshTargets().map(({ id, label, generation }) => ({
id,
label,
...(generation === undefined ? {} : { generation })
}))
}
export const SSH_METHODS: RpcMethod[] = [
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
capRemovedSshTargetTombstones,
collectAutomationReferencedSshTargetIds
} from './removed-ssh-target-tombstone-retention'
import type { RemovedSshTargetTombstone } from '../../shared/ssh-types'
function tombstones(count: number): RemovedSshTargetTombstone[] {
return Array.from({ length: count }, (_, index) => ({
oldTargetId: `ssh-${index}`,
host: 'h',
port: 22,
username: 'u',
label: `Host ${index}`,
removedAt: index
}))
}
describe('capRemovedSshTargetTombstones', () => {
it('drops the oldest unreferenced records first', () => {
const capped = capRemovedSshTargetTombstones(tombstones(5), new Set(), 3)
expect(capped.map((entry) => entry.oldTargetId)).toEqual(['ssh-2', 'ssh-3', 'ssh-4'])
})
it('retains referenced removal evidence past the cap', () => {
const capped = capRemovedSshTargetTombstones(tombstones(5), new Set(['ssh-0']), 3)
expect(capped.map((entry) => entry.oldTargetId)).toEqual(['ssh-0', 'ssh-3', 'ssh-4'])
})
it('keeps everything when every record is still referenced', () => {
const all = tombstones(5)
const referenced = new Set(all.map((entry) => entry.oldTargetId))
expect(capRemovedSshTargetTombstones(all, referenced, 2)).toHaveLength(5)
})
})
describe('collectAutomationReferencedSshTargetIds', () => {
it('collects only non-empty SSH selectors', () => {
expect(
collectAutomationReferencedSshTargetIds([
{ executionTargetType: 'ssh', executionTargetId: 'ssh-1' },
{ executionTargetType: 'ssh', executionTargetId: '' },
{ executionTargetType: 'local', executionTargetId: 'local' }
])
).toEqual(new Set(['ssh-1']))
})
})
@@ -0,0 +1,75 @@
import type { RemovedSshTargetTombstone } from '../../shared/ssh-types'
import type { PersistedAutomationHostFilter } from '../../shared/automation-host-filter'
import { parseHostStableKey } from '../../shared/automation-owner-key'
// Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded.
export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50
/**
* Apply the tombstone cap while retaining positive removal evidence something
* still depends on. A tombstone referenced by a stored automation is the only
* proof that its ghost host ever existed, so evicting it would silently turn an
* orphaned automation into an unexplained record. Referenced tombstones are kept
* past the cap; unreferenced ones are dropped oldest-first.
*/
export function capRemovedSshTargetTombstones(
tombstones: readonly RemovedSshTargetTombstone[],
referencedTargetIds: ReadonlySet<string>,
cap = MAX_REMOVED_SSH_TARGET_TOMBSTONES
): RemovedSshTargetTombstone[] {
if (tombstones.length <= cap) {
return [...tombstones]
}
const unreferencedToDrop = new Set<number>()
let dropCount = tombstones.length - cap
for (let index = 0; index < tombstones.length && dropCount > 0; index += 1) {
if (!referencedTargetIds.has(tombstones[index].oldTargetId)) {
unreferencedToDrop.add(index)
dropCount -= 1
}
}
return tombstones.filter((_, index) => !unreferencedToDrop.has(index))
}
/** SSH target ids stored automations still point at — the tombstones that must survive the cap. */
export function collectAutomationReferencedSshTargetIds(
automations: readonly { executionTargetType: string; executionTargetId: string }[]
): Set<string> {
const ids = new Set<string>()
for (const automation of automations) {
if (automation.executionTargetType === 'ssh' && automation.executionTargetId) {
ids.add(automation.executionTargetId)
}
}
return ids
}
/** Desktop SSH target id the persisted Automations host filter still names, if any. */
export function persistedAutomationHostFilterSshTargetId(
filter: PersistedAutomationHostFilter | undefined
): string | null {
if (filter?.kind !== 'host') {
return null
}
const host = parseHostStableKey(filter.hostKey)
return host?.authority.kind === 'desktop' && host.selector.kind === 'ssh'
? host.selector.targetId
: null
}
export type SshTargetRemovalEvidenceDependents = {
automations: readonly { executionTargetType: string; executionTargetId: string }[]
automationHostFilter?: PersistedAutomationHostFilter
}
/** Everything that still depends on removal evidence: a tombstone for one of these is never discarded. */
export function collectSshTargetRemovalEvidenceDependencies(
input: SshTargetRemovalEvidenceDependents
): Set<string> {
const ids = collectAutomationReferencedSshTargetIds(input.automations)
const filterTargetId = persistedAutomationHostFilterSshTargetId(input.automationHostFilter)
if (filterTargetId) {
ids.add(filterTargetId)
}
return ids
}
+14 -5
View File
@@ -17,8 +17,19 @@ function createMockStore() {
let deletedAliases: string[] = []
const removedTombstones: RemovedSshTargetTombstone[] = []
const reassignments: { oldTargetId: string; newTargetId: string }[] = []
let generationCounter = 0
const dropTombstone = (oldTargetId: string) => {
const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...kept)
}
return {
allocateSshTargetGeneration: vi.fn(() => {
generationCounter += 1
return generationCounter
}),
getSshTargets: vi.fn(() => [...targets]),
getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)),
addSshTarget: vi.fn((target: SshTarget) => targets.push(target)),
@@ -56,11 +67,9 @@ function createMockStore() {
removedTombstones.length = 0
removedTombstones.push(...filtered, tombstone)
}),
removeRemovedSshTargetTombstone: vi.fn((oldTargetId: string) => {
const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...kept)
}),
removeRemovedSshTargetTombstone: vi.fn(dropTombstone),
// Nothing in this mock stores automations, so releasing always drops.
releaseRemovedSshTargetTombstone: vi.fn(dropTombstone),
reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => {
reassignments.push({ oldTargetId, newTargetId })
// Pretend one repo referenced the old id.
+10 -2
View File
@@ -41,7 +41,9 @@ export class SshConnectionStore {
// 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)}`
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.
@@ -69,6 +71,8 @@ export class SshConnectionStore {
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 }
: {})
@@ -216,7 +220,11 @@ export class SshConnectionStore {
changed.push(updated)
}
} else {
const inserted: SshTarget = { ...candidate, source: 'ssh-config' }
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
+30
View File
@@ -1,4 +1,6 @@
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
import { toSshExecutionHostId } from '../../shared/execution-host'
@@ -89,6 +91,34 @@ export function migrateWorkspaceSessionSshTargetId(
return changed
}
/**
* Re-point folder-workspace scopes pinned to the old SSH target.
*
* A folder workspace carries its own connection/host stamp, and its group can
* carry the scope connection — neither is a repo row, so the repo sweep misses
* both and the workspace (plus any automation pinned to it) stays on the dead id.
*/
export function migrateFolderWorkspaceHostSshTargetId(
scope: { folderWorkspaces?: FolderWorkspace[]; projectGroups?: ProjectGroup[] },
oldTargetId: string,
newTargetId: string
): boolean {
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
let changed = false
for (const entry of [...(scope.folderWorkspaces ?? []), ...(scope.projectGroups ?? [])]) {
if (entry.connectionId === oldTargetId) {
entry.connectionId = newTargetId
changed = true
}
if (entry.executionHostId === oldHostId) {
entry.executionHostId = newHostId
changed = true
}
}
return changed
}
/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */
export function migrateUiHostScopeSshTargetId(
ui: PersistedUIState,
+23 -1
View File
@@ -23,7 +23,7 @@ function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) {
let current = [...tombstones]
const store = {
getRemovedSshTargetTombstones: () => [...current],
removeRemovedSshTargetTombstone: (oldTargetId: string) => {
releaseRemovedSshTargetTombstone: (oldTargetId: string) => {
current = current.filter((t) => t.oldTargetId !== oldTargetId)
},
reassignSshTargetId: (oldTargetId: string, newTargetId: string) => {
@@ -179,6 +179,28 @@ describe('readoptOrphanedWorkspacesForTarget', () => {
)
expect(readoptions).toHaveLength(1)
})
it('never re-adopts through a synthetic automation-scan tombstone', () => {
// A ghost synthesized from a stored automation has no host identity, so its
// empty tuple must not wildcard-match a real, unrelated new target.
const fake = makeFakeStore([
tombstone({ host: '', port: 0, username: '', origin: 'automation-scan' })
])
expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toHaveLength(0)
expect(fake.reassigned).toHaveLength(0)
expect(fake.remaining()).toHaveLength(1) // evidence preserved for the orphan entry
})
it('never re-adopts a synthetic tombstone even when a target has empty identity fields', () => {
const fake = makeFakeStore([
tombstone({ host: '', port: 0, username: '', origin: 'automation-scan' })
])
const readoptions = readoptOrphanedWorkspacesForTarget(
fake.store,
makeTarget({ configHost: undefined, host: '', port: 0, username: '' })
)
expect(readoptions).toHaveLength(0)
})
})
describe('buildRemovedSshTargetTombstone', () => {
+11 -4
View File
@@ -14,7 +14,8 @@ import { meaningfulSshAlias, sshEndpointKey, type SshIdentityFields } from './ss
* minted and nothing links the old workspaces to it. We bridge that gap using
* removal-time tombstones ({ oldTargetId, configHost, host, username, port }):
* on add/import we match the new target's identity to a tombstone and re-point
* every repo/worktree from the old id to the new one.
* every repo/worktree from the old id to the new one, together with the stored
* automations and persisted host filter pinned to it.
*
* Matching is intentionally strict — configHost (alias) first, then the
* host+username+port tuple — so we only auto-reattach on a confident identity
@@ -36,6 +37,11 @@ function tombstoneMatches(
// or a second alias for the same endpoint would steal the first's workspaces.
return targetAlias === tombstoneAlias
}
// A synthetic tombstone (built from an automation's dead target reference) has
// no real host identity, so the tuple would be a wildcard — never match on it.
if (tombstone.origin === 'automation-scan') {
return false
}
// Fallback: identical host+user+port. Used when either side has no real alias
// (manual adds default configHost to host), so a different account or port on
// the same host is correctly treated as a different target.
@@ -61,7 +67,7 @@ export function readoptOrphanedWorkspacesForTarget(
// Why: a re-added target can't share the id of one that still exists, but
// guard anyway so we never re-point a live target onto itself.
if (tombstone.oldTargetId === newTarget.id) {
store.removeRemovedSshTargetTombstone(tombstone.oldTargetId)
store.releaseRemovedSshTargetTombstone(tombstone.oldTargetId)
continue
}
if (!tombstoneMatches(tombstone, newTarget)) {
@@ -72,8 +78,9 @@ export function readoptOrphanedWorkspacesForTarget(
readoptions.push({ oldTargetId: tombstone.oldTargetId, newTargetId: newTarget.id, repoIds })
}
// Consume the tombstone whether or not it re-pointed anything: the host has
// returned, so the record has served its purpose.
store.removeRemovedSshTargetTombstone(tombstone.oldTargetId)
// returned, so the record has served its purpose — unless an automation or the
// persisted filter still depends on that removal evidence, which retains it.
store.releaseRemovedSshTargetTombstone(tombstone.oldTargetId)
}
return readoptions
}
@@ -341,6 +341,7 @@ function registerRuntimeWindowLifecycle(
worktreeBaseStatus: (event) => send('worktree:baseStatus', event),
worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event),
reposChanged: () => send('repos:changed'),
automationsChanged: (payload) => send('automations:changed', payload),
activateWorktree: (
repoId,
worktreeId,
+1
View File
@@ -153,6 +153,7 @@ export type PreloadApi = {
export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api'
export type { AiVaultApi } from './api/ai-vault-api'
export type { AutomationsApi, ExternalAutomationManagerResult } from './api/automation-api'
export type { AppApi } from './api/app-api'
export type { BrowserApi, DetectedBrowserInfo, DetectedBrowserProfileInfo } from './api/browser-api'
export type { EmulatorApi } from './api/emulator-api'
+66 -14
View File
@@ -3,29 +3,80 @@ import type {
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
ExternalAutomationCreateInput,
ExternalAutomationActionInput,
ExternalAutomationManager,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput,
AutomationRun,
AutomationPrecheckResult,
AutomationUpdateInput
} from '../../shared/automations-types'
import type {
AutomationListResult,
AutomationListScopeSelector
} from '../../shared/automation-list-scope'
import type {
AutomationDestination,
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
} from '../../shared/external-automation-scope'
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
/**
* One host+provider probe result. `manager: null` with `error: null` means the
* probe succeeded and nothing is configured there — distinct from a failure,
* which a caller must never render as an empty host.
*/
export type ExternalAutomationManagerResult = {
manager: ExternalAutomationManager | null
error: string | null
updatedAt: number
}
export type AutomationsApi = {
list: () => Promise<Automation[]>
listRuns: (args?: { automationId?: string }) => Promise<AutomationRun[]>
listExternalManagers: () => Promise<ExternalAutomationManager[]>
listExternalRuns: (input: ExternalAutomationRunsInput) => Promise<ExternalAutomationRunsPage>
createExternal: (input: ExternalAutomationCreateInput) => Promise<void>
updateExternal: (input: ExternalAutomationUpdateInput) => Promise<void>
runExternalAction: (input: ExternalAutomationActionInput) => Promise<void>
create: (input: AutomationCreateInput) => Promise<Automation>
update: (args: { id: string; updates: AutomationUpdateInput }) => Promise<Automation>
delete: (args: { id: string }) => Promise<void>
runNow: (args: { id: string }) => Promise<AutomationRun>
/** Host-scoped read of the desktop authority; the caller's captured SSH generation is verified. */
listScoped: (params: { selector: AutomationListScopeSelector }) => Promise<AutomationListResult>
listRuns: (args?: {
automationId?: string
expectedOwner?: AutomationOwnerPrecondition
}) => Promise<AutomationRun[]>
/**
* Scoped external-manager surface. Every request carries the captured
* desktop owner it was built from; the provider target and manager ID are
* derived from that owner in the main process and are never sent alongside it.
*/
listExternalManagerForOwner: (
request: ScopedExternalManagerListRequest
) => Promise<ExternalAutomationManagerResult>
listExternalRunsForOwner: (
request: ScopedExternalManagerRunsRequest
) => Promise<ExternalAutomationRunsPage>
createExternalForOwner: (request: ScopedExternalManagerCreateRequest) => Promise<void>
updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest) => Promise<void>
runExternalActionForOwner: (request: ScopedExternalManagerActionRequest) => Promise<void>
/** Probes outside the retained owners are cancelled; an empty list retains none. */
retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }) => Promise<void>
create: (
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
) => Promise<Automation>
update: (args: {
id: string
updates: AutomationUpdateInput
expectedOwner?: AutomationOwnerPrecondition
destination?: AutomationDestination
}) => Promise<Automation>
delete: (args: { id: string; expectedOwner?: AutomationOwnerPrecondition }) => Promise<void>
runNow: (args: {
id: string
expectedOwner?: AutomationOwnerPrecondition
}) => Promise<AutomationRun>
runPrecheck: (args: {
automationId: string
runId: string
@@ -34,4 +85,5 @@ export type AutomationsApi = {
snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise<number>
rendererReady: () => Promise<void>
onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void
onChanged: (callback: (payload: AutomationsChangedPayload) => void) => () => void
}
+63 -24
View File
@@ -257,6 +257,7 @@ import type {
PluginHostInstallSource,
PluginHostListEntry,
PluginHostLogLine,
ExternalAutomationManagerResult,
PreloadApi
} from './api-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
@@ -273,16 +274,28 @@ import type {
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
ExternalAutomationCreateInput,
ExternalAutomationActionInput,
ExternalAutomationManager,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput,
AutomationRun,
AutomationPrecheckResult,
AutomationUpdateInput
} from '../shared/automations-types'
import type {
AutomationListResult,
AutomationListScopeSelector
} from '../shared/automation-list-scope'
import type {
AutomationDestination,
AutomationOwnerPrecondition
} from '../shared/automation-owner-precondition'
import type { AutomationOwnerRef } from '../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
} from '../shared/external-automation-scope'
import type { AutomationsChangedPayload } from '../shared/runtime-client-events'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
import type {
AiVaultDeleteSessionArgs,
@@ -4842,25 +4855,45 @@ const api = {
automations: {
list: (): Promise<Automation[]> => ipcRenderer.invoke('automations:list'),
listRuns: (args?: { automationId?: string }): Promise<AutomationRun[]> =>
ipcRenderer.invoke('automations:listRuns', args),
listExternalManagers: (): Promise<ExternalAutomationManager[]> =>
ipcRenderer.invoke('automations:listExternalManagers'),
listExternalRuns: (input: ExternalAutomationRunsInput): Promise<ExternalAutomationRunsPage> =>
ipcRenderer.invoke('automations:listExternalRuns', input),
createExternal: (input: ExternalAutomationCreateInput): Promise<void> =>
ipcRenderer.invoke('automations:createExternal', input),
updateExternal: (input: ExternalAutomationUpdateInput): Promise<void> =>
ipcRenderer.invoke('automations:updateExternal', input),
runExternalAction: (input: ExternalAutomationActionInput): Promise<void> =>
ipcRenderer.invoke('automations:runExternalAction', input),
create: (input: AutomationCreateInput): Promise<Automation> =>
ipcRenderer.invoke('automations:create', input),
update: (args: { id: string; updates: AutomationUpdateInput }): Promise<Automation> =>
ipcRenderer.invoke('automations:update', args),
delete: (args: { id: string }): Promise<void> => ipcRenderer.invoke('automations:delete', args),
runNow: (args: { id: string }): Promise<AutomationRun> =>
ipcRenderer.invoke('automations:runNow', args),
listScoped: (params: {
selector: AutomationListScopeSelector
}): Promise<AutomationListResult> => ipcRenderer.invoke('automations:list', params),
listRuns: (args?: {
automationId?: string
expectedOwner?: AutomationOwnerPrecondition
}): Promise<AutomationRun[]> => ipcRenderer.invoke('automations:listRuns', args),
listExternalManagerForOwner: (
request: ScopedExternalManagerListRequest
): Promise<ExternalAutomationManagerResult> =>
ipcRenderer.invoke('automations:listExternalManagerForOwner', request),
listExternalRunsForOwner: (
request: ScopedExternalManagerRunsRequest
): Promise<ExternalAutomationRunsPage> =>
ipcRenderer.invoke('automations:listExternalRunsForOwner', request),
createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise<void> =>
ipcRenderer.invoke('automations:createExternalForOwner', request),
updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise<void> =>
ipcRenderer.invoke('automations:updateExternalForOwner', request),
runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise<void> =>
ipcRenderer.invoke('automations:runExternalActionForOwner', request),
retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise<void> =>
ipcRenderer.invoke('automations:retainExternalScopes', request),
create: (
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
): Promise<Automation> => ipcRenderer.invoke('automations:create', input, options),
update: (args: {
id: string
updates: AutomationUpdateInput
expectedOwner?: AutomationOwnerPrecondition
destination?: AutomationDestination
}): Promise<Automation> => ipcRenderer.invoke('automations:update', args),
delete: (args: { id: string; expectedOwner?: AutomationOwnerPrecondition }): Promise<void> =>
ipcRenderer.invoke('automations:delete', args),
runNow: (args: {
id: string
expectedOwner?: AutomationOwnerPrecondition
}): Promise<AutomationRun> => ipcRenderer.invoke('automations:runNow', args),
runPrecheck: (args: {
automationId: string
runId: string
@@ -4876,6 +4909,12 @@ const api = {
callback(request)
ipcRenderer.on('automations:dispatchRequested', listener)
return () => ipcRenderer.removeListener('automations:dispatchRequested', listener)
},
onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) =>
callback(payload)
ipcRenderer.on('automations:changed', listener)
return () => ipcRenderer.removeListener('automations:changed', listener)
}
},
@@ -0,0 +1,83 @@
import React from 'react'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { translate } from '@/i18n/i18n'
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
import { Field } from './automation-page-parts'
import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
/**
* Where the new automation will be stored, named on the form before submit.
*
* This is the storage authority, not the workspace's execution host — the two
* routinely differ, and only this one decides which machine keeps and schedules
* the record. A concrete host filter only preselects: the list filter narrows a
* view, so it must not decide where a new record is allowed to live.
*/
export function AutomationCreateDestinationField({
control,
labelClassName
}: {
control: AutomationCreateDestinationControl
labelClassName?: string
}): React.JSX.Element {
const selected = control.resolution.status === 'ready' ? control.resolution.entry : null
const groups = groupAutomationHostEntriesByAuthority(
control.entries.filter((entry) => entry.kind !== 'orphan' && entry.owner)
)
const label = translate('auto.components.automations.createDestination.label', 'Create on')
return (
<Field label={label} labelClassName={labelClassName}>
<Select value={selected?.stableKey ?? ''} onValueChange={control.onSelect}>
<SelectTrigger aria-label={label} className="h-9 w-full min-w-0">
<SelectValue
placeholder={translate(
'auto.components.automations.createDestination.placeholder',
'Select a host'
)}
/>
</SelectTrigger>
<SelectContent>
{groups.map((group) => (
<SelectGroup key={group.authorityKey} data-authority-key={group.authorityKey}>
<SelectLabel>{group.authorityLabel}</SelectLabel>
{group.entries.map((entry) => (
<SelectItem
key={entry.stableKey}
value={entry.stableKey}
data-host-stable-key={entry.stableKey}
>
<span className="flex min-w-0 items-center gap-2">
<AutomationHostLabel entry={entry} className="min-w-0" />
<AutomationHostStatusBadges entry={entry} />
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selected
? translate(
'auto.components.automations.createDestination.storedOn',
'Stored and scheduled by {authority}.'
).replace('{authority}', selected.authorityLabel)
: translate(
'auto.components.automations.createDestination.unselected',
'Choose the host this automation will be created on.'
)}
</p>
</Field>
)
}
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
/**
* A record Orca switched off used to be indistinguishable from one the user
* switched off — same "Paused", no reason, no next step. These pin the one new
* state to the migration's own stamp, and pin the other two to reading exactly
* as they did before it existed.
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { Automation } from '../../../../shared/automations-types'
import { AutomationDetail } from './AutomationDetail'
import { makeAutomation } from './automations-page-fixtures'
const roots: Root[] = []
async function render(overrides: Partial<Automation>): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<TooltipProvider>
<AutomationDetail
automation={makeAutomation(overrides)}
runs={[]}
projectName="orca"
workspaceName="main"
projectDefaultBaseRef="main"
runNowAvailability={null}
now={0}
onRunNow={vi.fn()}
onEdit={vi.fn()}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
</TooltipProvider>
)
})
return container
}
function notice(container: HTMLDivElement): HTMLElement | null {
return container.querySelector('[data-testid="automation-enablement-notice"]')
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(async () => {
await act(async () => {
roots.splice(0).forEach((root) => root.unmount())
})
document.body.innerHTML = ''
})
describe('AutomationDetail enablement', () => {
it('says Orca paused it, why, and what to do about it', async () => {
const container = await render({ enabled: false, enabledDecidedBy: 'owner_migration' })
expect(container.textContent).toContain('Paused by Orca')
expect(notice(container)?.textContent).toContain('You did not pause it.')
// The affordance is the point: without it the user is told it stopped and
// left with no move. Re-adding the host and resuming are different fixes.
expect(notice(container)?.textContent).toContain('Re-add that host')
expect(notice(container)?.textContent).toContain('resume the automation')
})
it('leaves a user-paused record reading as a plain pause', async () => {
const container = await render({ enabled: false, enabledDecidedBy: 'user' })
expect(container.textContent).toContain('Paused')
expect(container.textContent).not.toContain('Paused by Orca')
expect(notice(container)).toBeNull()
})
it('adds no new state to a pre-stamp record, which is every record until one is disabled', async () => {
const container = await render({ enabled: false })
expect(container.textContent).toContain('Paused')
expect(container.textContent).not.toContain('Paused by Orca')
expect(notice(container)).toBeNull()
})
it('never explains a running automation', async () => {
// The authority restamps to `user` on resume, but the renderer holds the
// old record until the list comes back — so `enabled` has to win the race.
const container = await render({ enabled: true, enabledDecidedBy: 'owner_migration' })
expect(container.textContent).toContain('Enabled')
expect(notice(container)).toBeNull()
})
})
@@ -14,6 +14,7 @@ import {
summarizeAutomationRunUsage
} from './automation-usage-model'
import type { AutomationTargetAvailability } from './automation-target-availability'
import { automationEnablementNotice, automationPausedLabel } from './automation-enablement-notice'
import { getAutomationSourceDisplay } from './automation-source-display'
import { translate } from '@/i18n/i18n'
@@ -133,6 +134,7 @@ export function AutomationDetail({
: workspaceName
const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById)
const runNowDisabled = runNowAvailability?.canRunNow === false
const enablementNotice = automationEnablementNotice(automation)
return (
<div className="flex w-full flex-col gap-4">
@@ -143,7 +145,7 @@ export function AutomationDetail({
<Badge variant={automation.enabled ? 'secondary' : 'outline'}>
{automation.enabled
? translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')
: translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')}
: automationPausedLabel(automation)}
</Badge>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
@@ -209,6 +211,16 @@ export function AutomationDetail({
</div>
</div>
{enablementNotice ? (
<div
data-testid="automation-enablement-notice"
className="rounded-md border border-border/50 bg-muted/40 p-3 text-sm shadow-sm"
>
<p className="font-medium text-foreground">{enablementNotice.reason}</p>
<p className="mt-1 text-muted-foreground">{enablementNotice.recovery}</p>
</div>
) : null}
{automation.executionTargetType === 'ssh' ? (
<div className="rounded-md border border-border/50 bg-muted/50 p-3 text-sm text-muted-foreground shadow-sm">
{translate(
@@ -14,6 +14,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SetupDecision } from '../../../../shared/worktree/create-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget'
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import { AutomationEditorDialogFooter } from './AutomationEditorDialogFooter'
import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader'
import { getAutomationPromptEditorRoot } from './AutomationEditorPromptEditor'
@@ -67,6 +70,12 @@ type AutomationEditorDialogProps = {
worktrees: Worktree[]
settings: GlobalSettings | null
draft: AutomationDraft
/** Present only while creating an Orca automation; editing keeps the record's captured owner. */
createDestination?: AutomationCreateDestinationControl
/** Why a save was refused. Belongs here rather than on the page: this dialog covers it. */
notice?: AutomationActionNotice | null
onNoticeRecover?: () => void
onNoticeDismiss?: () => void
onProjectChange: (projectId: string) => void
getRepoHostLabel?: (repo: Repo) => string | null | undefined
onCreateTargetChange: (target: AutomationCreateTarget) => void
@@ -92,6 +101,10 @@ export function AutomationEditorDialog({
worktrees,
settings,
draft,
createDestination,
notice,
onNoticeRecover,
onNoticeDismiss,
onProjectChange,
getRepoHostLabel,
onCreateTargetChange,
@@ -170,6 +183,8 @@ export function AutomationEditorDialog({
onDismiss={() => onOpenChange(false)}
/>
<AutomationEditorSettingsSidebar
isCreateMode={isCreateMode}
createDestination={createDestination}
isHermesTarget={isHermesTarget}
isHermesCreate={isHermesCreate}
repos={repos}
@@ -191,6 +206,13 @@ export function AutomationEditorDialog({
/>
</div>
<AutomationOwnerConflictNotice
notice={notice ?? null}
className="mx-5 mb-1"
onRecover={onNoticeRecover}
onDismiss={onNoticeDismiss}
/>
<AutomationEditorDialogFooter
isEditing={isEditing}
isEditingExternal={isEditingExternal}
@@ -19,9 +19,13 @@ import { AutomationSchedulePicker } from './AutomationSchedulePicker'
import { AutomationSessionField } from './AutomationSessionField'
import { AutomationSetupDecisionField } from './AutomationSetupDecisionField'
import { AutomationWorkspaceField } from './AutomationWorkspaceField'
import { AutomationCreateDestinationField } from './AutomationCreateDestinationField'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { AutomationDraft } from './AutomationEditorDialog'
type AutomationEditorSettingsSidebarProps = {
isCreateMode?: boolean
createDestination?: AutomationCreateDestinationControl
isHermesTarget: boolean
isHermesCreate: boolean
repos: readonly Repo[]
@@ -43,6 +47,8 @@ type AutomationEditorSettingsSidebarProps = {
}
export function AutomationEditorSettingsSidebar({
isCreateMode,
createDestination,
isHermesTarget,
isHermesCreate,
repos,
@@ -105,6 +111,14 @@ export function AutomationEditorSettingsSidebar({
</div>
</div>
</div>
{createDestination && isCreateMode && !isHermesTarget ? (
<div className="mb-4">
<AutomationCreateDestinationField
control={createDestination}
labelClassName={AUTOMATION_EDITOR_SECTION_LABEL_CLASS}
/>
</div>
) : null}
<Field
className="mb-4"
labelClassName={AUTOMATION_EDITOR_SECTION_LABEL_CLASS}
@@ -0,0 +1,168 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function entry(overrides: Partial<AutomationHostCatalogEntry> = {}): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
owner: null,
stableKey: 'host:desktop:ssh:t1',
label: 'web-01',
authorityLabel: 'This computer',
kind: 'ssh',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped',
...overrides
}
}
function render(node: React.ReactNode): void {
act(() => {
root.render(<TooltipProvider>{node}</TooltipProvider>)
})
}
function statusIds(axis?: string): string[] {
const selector = axis ? `[data-status-axis="${axis}"]` : '[data-status-id]'
return [...container.querySelectorAll(selector)].map(
(node) => node.getAttribute('data-status-id') ?? ''
)
}
describe('AutomationHostStatusBadges', () => {
it('renders nothing when both axes rest healthy and the contract is scoped', () => {
render(<AutomationHostStatusBadges entry={entry()} />)
expect(statusIds()).toEqual([])
})
it('renders the two health axes as separate badges', () => {
render(
<AutomationHostStatusBadges
entry={entry({ authorityHealth: 'stale-error', executionHealth: 'disconnected' })}
/>
)
expect(statusIds('authority')).toEqual(['authority-stale-error'])
expect(statusIds('execution')).toEqual(['execution-disconnected'])
})
it('shows an execution failure while the authority is fresh', () => {
render(<AutomationHostStatusBadges entry={entry({ executionHealth: 'disconnected' })} />)
expect(statusIds('authority')).toEqual([])
expect(statusIds('execution')).toEqual(['execution-disconnected'])
})
it('shows an authority failure while the target is connected', () => {
render(<AutomationHostStatusBadges entry={entry({ authorityHealth: 'unavailable' })} />)
expect(statusIds('authority')).toEqual(['authority-unavailable'])
expect(statusIds('execution')).toEqual([])
})
it('distinguishes an incompatible authority from a legacy-unscoped contract', () => {
render(<AutomationHostStatusBadges entry={entry({ authorityHealth: 'incompatible' })} />)
const incompatible = statusIds()
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'legacy-unscoped' })} />)
const legacy = statusIds()
expect(incompatible).toContain('authority-incompatible')
expect(legacy).toContain('query-legacy-unscoped')
expect(incompatible).not.toEqual(legacy)
})
it('separates the incompatible query contract from the incompatible authority', () => {
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'incompatible' })} />)
expect(statusIds('query')).toEqual(['query-incompatible'])
expect(statusIds('authority')).toEqual([])
})
it('names the removed host, not the server, for a ghost entry', () => {
render(
<AutomationHostStatusBadges
entry={entry({
catalogState: 'removed',
executionHealth: 'unavailable',
querySupport: 'legacy-unscoped',
scopeGap: 'target-removed'
})}
/>
)
expect(statusIds('query')).toEqual(['query-target-removed'])
expect(
container.querySelector('[data-status-id="query-target-removed"]')?.getAttribute('aria-label')
).toBe(
'View only. This host was removed, so its automations can no longer be edited or run from here.'
)
})
it('separates an unverified target from a server that lacks host scoping', () => {
render(
<AutomationHostStatusBadges
entry={entry({
catalogState: 'unhydrated',
executionHealth: 'unknown',
querySupport: 'legacy-unscoped',
scopeGap: 'target-unverified'
})}
/>
)
expect(statusIds('query')).toEqual(['query-target-unverified'])
expect(
container
.querySelector('[data-status-id="query-target-unverified"]')
?.getAttribute('aria-label')
).toBe(
'View only. This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.'
)
})
it('names both the label and the explanation in the accessible name', () => {
render(<AutomationHostStatusBadges entry={entry({ executionHealth: 'disconnected' })} />)
const badge = container.querySelector('[data-status-id="execution-disconnected"]')
expect(badge?.getAttribute('aria-label')).toBe(
'Not connected. Automations on this host cannot run until the connection is restored.'
)
})
it('renders resting states when asked to show healthy', () => {
render(<AutomationHostStatusBadges entry={entry()} showHealthy />)
expect(statusIds()).toEqual(['authority-fresh', 'execution-connected'])
})
})
describe('AutomationHostLabel', () => {
it('keeps the full name in the accessible name while the visible label truncates', () => {
render(<AutomationHostLabel entry={entry({ label: 'a-very-long-host-name-that-truncates' })} />)
const labelled = container.querySelector('[data-host-stable-key]')
expect(labelled?.getAttribute('aria-label')).toBe('a-very-long-host-name-that-truncates')
expect(container.querySelector('.truncate')?.textContent).toBe(
'a-very-long-host-name-that-truncates'
)
})
it('qualifies the accessible name with the authority when asked', () => {
render(<AutomationHostLabel entry={entry()} showAuthority />)
expect(container.querySelector('[data-host-stable-key]')?.getAttribute('aria-label')).toBe(
'This computer · web-01'
)
})
})
@@ -0,0 +1,126 @@
import React from 'react'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import { hostScopeDescriptor } from './automation-host-scope-descriptors'
import {
authorityHealthDescriptor,
executionHealthDescriptor,
type AutomationHostStatusDescriptor,
type AutomationHostStatusTone
} from './automation-host-status-descriptors'
/**
* Host identity and status chips. The two health axes render as two chips and
* never merge: a host can be freshly loaded and disconnected at the same time.
*/
type BadgeStyle = { variant: React.ComponentProps<typeof Badge>['variant']; className?: string }
// `Badge` has no muted variant and this design does not add one — quiet reads as outline + muted text.
const TONE_STYLE: Record<AutomationHostStatusTone, BadgeStyle> = {
neutral: { variant: 'outline' },
quiet: { variant: 'outline', className: 'text-muted-foreground' },
attention: { variant: 'secondary' },
error: { variant: 'destructive' }
}
type AutomationHostLabelProps = {
entry: AutomationHostCatalogEntry
/** Host accent color; falls back to a neutral token so no new color is invented. */
color?: string | null
showAuthority?: boolean
className?: string
}
export function AutomationHostLabel({
entry,
color,
showAuthority = false,
className
}: AutomationHostLabelProps): React.JSX.Element {
const accessibleName = showAuthority ? `${entry.authorityLabel} · ${entry.label}` : entry.label
return (
<Tooltip>
<TooltipTrigger asChild>
{/* Truncation lives on the inner label, so the full name stays in the accessible name. */}
<span
className={cn('inline-flex min-w-0 items-center gap-1.5 text-xs', className)}
aria-label={accessibleName}
data-host-stable-key={entry.stableKey}
>
<RepoBadgeLabel
name={entry.label}
color={color ?? 'var(--muted-foreground)'}
className="max-w-full"
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{accessibleName}
</TooltipContent>
</Tooltip>
)
}
type StatusBadgeProps = {
descriptor: AutomationHostStatusDescriptor
axis: 'authority' | 'execution' | 'query'
}
function StatusBadge({ descriptor, axis }: StatusBadgeProps): React.JSX.Element {
const style = TONE_STYLE[descriptor.tone]
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={style.variant}
className={cn('max-w-[12rem] text-[11px]', style.className)}
data-status-axis={axis}
data-status-id={descriptor.id}
aria-label={`${descriptor.label}. ${descriptor.description}`}
>
<span className="truncate">{descriptor.label}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{descriptor.description}
</TooltipContent>
</Tooltip>
)
}
type AutomationHostStatusBadgesProps = {
entry: AutomationHostCatalogEntry
/** Render the resting states too; off by default so healthy rows stay quiet. */
showHealthy?: boolean
className?: string
}
export function AutomationHostStatusBadges({
entry,
showHealthy = false,
className
}: AutomationHostStatusBadgesProps): React.JSX.Element | null {
const authority = authorityHealthDescriptor(entry.authorityHealth)
const execution = executionHealthDescriptor(entry.executionHealth)
const query = hostScopeDescriptor(entry)
const showAuthority = showHealthy || !authority.isDefault
const showExecution = showHealthy || !execution.isDefault
if (!showAuthority && !showExecution && !query) {
return null
}
return (
<span className={cn('inline-flex min-w-0 items-center gap-1', className)}>
{showAuthority ? <StatusBadge descriptor={authority} axis="authority" /> : null}
{showExecution ? <StatusBadge descriptor={execution} axis="execution" /> : null}
{query ? <StatusBadge descriptor={query} axis="query" /> : null}
</span>
)
}
@@ -0,0 +1,205 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
AutomationHostFilterNotice,
AutomationHostLoadSummary,
automationHostLoadSummaryMessage
} from './AutomationHostFilterNotice'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type {
AutomationHostFilterResolution,
AutomationHostFilterStatus
} from './automation-host-filter-resolution'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function entry(overrides: Partial<AutomationHostCatalogEntry> = {}): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
owner: null,
stableKey: 'host:desktop:ssh:t1',
label: 'web-01',
authorityLabel: 'This computer',
kind: 'ssh',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped',
...overrides
}
}
function resolution(
status: AutomationHostFilterStatus,
overrides: Partial<AutomationHostFilterResolution> = {}
): AutomationHostFilterResolution {
return {
effective: status === 'all' ? { kind: 'all' } : { kind: 'host', host: entry().stableRef },
entry: status === 'all' ? null : entry(),
status,
announceFallback: false,
...overrides
}
}
function renderNotice(
value: AutomationHostFilterResolution,
onRecover?: (action: 'retry' | 'reconnect' | 'update-server') => void
): void {
act(() => {
root.render(
<TooltipProvider>
<AutomationHostFilterNotice resolution={value} onRecover={onRecover} />
</TooltipProvider>
)
})
}
function liveRegionText(): string {
return [...container.querySelectorAll('[aria-live="polite"]')]
.map((node) => node.textContent ?? '')
.join(' ')
.trim()
}
describe('AutomationHostFilterNotice', () => {
it('stays silent when the selection is healthy', () => {
renderNotice(resolution('all'))
expect(container.querySelector('[data-filter-status]')).toBeNull()
renderNotice(resolution('ready'))
expect(container.querySelector('[data-filter-status]')).toBeNull()
})
const NOTICED: AutomationHostFilterStatus[] = ['loading', 'unavailable', 'ghost', 'removed']
it.each(NOTICED)('surfaces the %s status inline', (status) => {
renderNotice(resolution(status))
const notice = container.querySelector('[data-filter-status]')
expect(notice?.getAttribute('data-filter-status')).toBe(status)
expect(notice?.textContent?.length).toBeGreaterThan(0)
})
it('reports a removed host as still listed rather than as data loss', () => {
renderNotice(resolution('ghost'))
expect(container.querySelector('[data-filter-status="ghost"]')?.textContent).toContain(
'web-01 was removed. Automations still assigned to it remain listed.'
)
})
it('announces the fallback to All hosts', () => {
renderNotice(resolution('all', { announceFallback: true }))
expect(liveRegionText()).toBe('The selected host is no longer available. Showing all hosts.')
})
it('says nothing when no fallback happened', () => {
renderNotice(resolution('all'))
expect(liveRegionText()).toBe('')
})
it('keeps the polite region mounted while there is nothing to announce', () => {
renderNotice(resolution('all'))
expect(container.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull()
})
it('announces into the region that was already on the page', () => {
renderNotice(resolution('all'))
const before = container.querySelector('[role="status"][aria-live="polite"]')
renderNotice(resolution('all', { announceFallback: true }))
// A region inserted with its text already in it is not a change screen readers report.
expect(container.querySelector('[role="status"][aria-live="polite"]')).toBe(before)
})
it('offers the recovery action for the failure it actually has', () => {
const onRecover = vi.fn()
renderNotice(
resolution('unavailable', { entry: entry({ authorityHealth: 'unavailable' }) }),
onRecover
)
const button = container.querySelector('button')
expect(button?.textContent).toBe('Reconnect')
act(() => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onRecover).toHaveBeenCalledWith('reconnect')
})
it('offers Update server for a legacy-unscoped contract', () => {
renderNotice(
resolution('ghost', { entry: entry({ querySupport: 'legacy-unscoped' }) }),
() => undefined
)
expect(container.querySelector('button')?.textContent).toBe('Update server')
})
it('does not move focus when the notice appears', () => {
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
renderNotice(resolution('unavailable'))
expect(document.activeElement).toBe(input)
input.remove()
})
})
describe('AutomationHostLoadSummary', () => {
function renderSummary(failed: number, total: number): void {
act(() => {
root.render(<AutomationHostLoadSummary failedHostCount={failed} totalHostCount={total} />)
})
}
it('words the partial failure exactly as specified', () => {
expect(automationHostLoadSummaryMessage(2, 5)).toBe('2 of 5 hosts could not be loaded')
})
it('says nothing when every host loaded', () => {
renderSummary(0, 5)
expect(liveRegionText()).toBe('')
})
it('announces politely rather than assertively', () => {
renderSummary(2, 5)
const region = container.querySelector('[aria-live]')
expect(region?.getAttribute('aria-live')).toBe('polite')
expect(region?.textContent).toBe('2 of 5 hosts could not be loaded')
})
it('does not re-announce when a retry leaves the failed count unchanged', () => {
renderSummary(2, 5)
const first = container.querySelector('[aria-live]')?.firstChild
renderSummary(2, 6)
expect(container.querySelector('[aria-live]')?.textContent).toBe(
'2 of 5 hosts could not be loaded'
)
// The same text node survives, so assistive tech sees no change to announce.
expect(container.querySelector('[aria-live]')?.firstChild).toBe(first)
})
it('re-announces when the failed count changes', () => {
renderSummary(2, 5)
renderSummary(1, 5)
expect(container.querySelector('[aria-live]')?.textContent).toBe(
'1 of 5 hosts could not be loaded'
)
})
})
@@ -0,0 +1,143 @@
import React, { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { AutomationHostStatusBadges } from './AutomationHostBadges'
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
import {
automationHostRecoveryActions,
recoveryActionLabel,
type AutomationHostRecoveryAction
} from './automation-host-status-descriptors'
/**
* Inline status for the current host selection, plus the two polite
* announcements. Status appears inline rather than as a toast because the user
* has to read and act on it, and it must never steal focus.
*/
export function automationHostLoadSummaryMessage(
failedHostCount: number,
totalHostCount: number
): string | null {
if (failedHostCount <= 0) {
return null
}
return translate(
'auto.components.automations.hostSummary.partialFailure',
'{{failed}} of {{total}} hosts could not be loaded',
{ failed: failedHostCount, total: totalHostCount }
)
}
type AutomationHostLoadSummaryProps = {
failedHostCount: number
totalHostCount: number
className?: string
}
/** Polite and deduplicated: re-announces only when the failed count changes, not per retry. */
export function AutomationHostLoadSummary({
failedHostCount,
totalHostCount,
className
}: AutomationHostLoadSummaryProps): React.JSX.Element {
const [announced, setAnnounced] = useState<string>('')
// Why a ref: a host joining or leaving must not re-announce an unchanged set of failures.
const totalRef = useRef(totalHostCount)
totalRef.current = totalHostCount
useEffect(() => {
setAnnounced(automationHostLoadSummaryMessage(failedHostCount, totalRef.current) ?? '')
}, [failedHostCount])
return (
<div role="status" aria-live="polite" className={cn('sr-only', className)}>
{announced}
</div>
)
}
type AutomationHostFilterNoticeProps = {
resolution: AutomationHostFilterResolution
onRecover?: (action: AutomationHostRecoveryAction) => void
className?: string
}
function noticeCopy(resolution: AutomationHostFilterResolution): string | null {
const label =
resolution.entry?.label ??
translate('auto.components.automations.emptyState.unknownHost', 'this host')
switch (resolution.status) {
case 'loading':
return translate('auto.components.automations.hostNotice.loading', 'Loading host…')
case 'unavailable':
return translate(
'auto.components.automations.hostNotice.unavailable',
'{{hostLabel}} could not be reached. Showing the automations last loaded from it.',
{ hostLabel: label }
)
case 'ghost':
return translate(
'auto.components.automations.hostNotice.ghost',
'{{hostLabel}} was removed. Automations still assigned to it remain listed.',
{ hostLabel: label }
)
case 'removed':
return translate(
'auto.components.automations.hostNotice.removed',
'The selected host is no longer available. Showing all hosts.'
)
case 'all':
case 'ready':
return null
}
}
export function AutomationHostFilterNotice({
resolution,
onRecover,
className
}: AutomationHostFilterNoticeProps): React.JSX.Element {
const [announcedFallback, setAnnouncedFallback] = useState<string>('')
const message = noticeCopy(resolution)
const recovery = resolution.entry ? automationHostRecoveryActions(resolution.entry) : null
const action = recovery?.authority ?? recovery?.execution ?? null
useEffect(() => {
setAnnouncedFallback(
resolution.announceFallback
? translate(
'auto.components.automations.hostNotice.removed',
'The selected host is no longer available. Showing all hosts.'
)
: ''
)
}, [resolution.announceFallback])
return (
<>
{/* Outlives every notice state: a region inserted with its text already in it is announced by nothing. */}
<div role="status" aria-live="polite" className="sr-only">
{announcedFallback}
</div>
{message ? (
<div
data-filter-status={resolution.status}
className={cn(
'flex flex-wrap items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground',
className
)}
>
<span className="min-w-0 flex-1">{message}</span>
{resolution.entry ? <AutomationHostStatusBadges entry={resolution.entry} /> : null}
{action && onRecover ? (
<Button type="button" variant="outline" size="xs" onClick={() => onRecover(action)}>
{recoveryActionLabel(action)}
</Button>
) : null}
</div>
) : null}
</>
)
}
@@ -0,0 +1,250 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AutomationHostFilter } from '../../../../shared/automation-host-filter'
import { AutomationHostPicker } from './AutomationHostPicker'
import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function sshEntry(
targetId: string,
label: string,
overrides: Partial<AutomationHostCatalogEntry> = {}
): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } },
owner: null,
stableKey: `host:desktop:ssh:${targetId}`,
label,
authorityLabel: 'This computer',
kind: 'ssh',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped',
...overrides
}
}
function runtimeSelf(environmentId: string, label: string): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'runtime', environmentId }, selector: { kind: 'self' } },
owner: null,
stableKey: `host:runtime:${environmentId}:self`,
label,
authorityLabel: label,
kind: 'self',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped'
}
}
function manyEntries(count: number): AutomationHostCatalogEntry[] {
return Array.from({ length: count }, (_, index) =>
sshEntry(`t${index}`, `web-${String(index).padStart(2, '0')}`)
)
}
const ALL_HOSTS: AutomationHostFilterResolution = {
effective: { kind: 'all' },
entry: null,
status: 'all',
announceFallback: false
}
function render(
entries: readonly AutomationHostCatalogEntry[],
onSelect: (filter: AutomationHostFilter) => void = () => undefined,
resolution: AutomationHostFilterResolution = ALL_HOSTS
): void {
act(() => {
root.render(
<TooltipProvider>
<AutomationHostPicker entries={entries} resolution={resolution} onSelect={onSelect} />
</TooltipProvider>
)
})
}
function openCommandPicker(): void {
const trigger = container.querySelector('[data-picker-variant="command"]')
act(() => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
function visibleHostKeys(): string[] {
// The label inside each item repeats the key, so match the item element itself.
return [...document.querySelectorAll('[data-slot="command-item"][data-host-stable-key]')].map(
(node) => node.getAttribute('data-host-stable-key') ?? ''
)
}
function typeSearch(value: string): void {
const input = document.querySelector<HTMLInputElement>('[data-slot="command-input"]')
if (!input) {
throw new Error('command input not rendered')
}
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
describe('AutomationHostPicker primitive selection', () => {
it('uses the unsearchable Select at eight entries', () => {
render(manyEntries(8))
expect(container.querySelector('[data-picker-variant="select"]')).not.toBeNull()
expect(container.querySelector('[data-picker-variant="command"]')).toBeNull()
})
it('switches to the searchable Command picker at nine entries', () => {
render(manyEntries(9))
expect(container.querySelector('[data-picker-variant="select"]')).toBeNull()
expect(container.querySelector('[data-picker-variant="command"]')).not.toBeNull()
})
it('labels both variants Filter by host', () => {
render(manyEntries(8))
expect(
container.querySelector('[data-picker-variant="select"]')?.getAttribute('aria-label')
).toBe('Filter by host')
render(manyEntries(9))
expect(
container.querySelector('[data-picker-variant="command"]')?.getAttribute('aria-label')
).toBe('Filter by host')
})
it('names the retained selection rather than All hosts while it loads', () => {
render(manyEntries(9), () => undefined, {
effective: { kind: 'host', host: sshEntry('t0', 'web-00').stableRef },
entry: null,
status: 'loading',
announceFallback: false
})
expect(container.querySelector('[data-picker-variant="command"]')?.textContent).toContain(
'Loading host…'
)
})
it('names the retained selection in the Select variant too', () => {
render(manyEntries(8), () => undefined, {
effective: { kind: 'host', host: sshEntry('t0', 'web-00').stableRef },
entry: null,
status: 'loading',
announceFallback: false
})
const trigger = container.querySelector('[data-picker-variant="select"]')
expect(trigger?.textContent).toContain('Loading host…')
expect(trigger?.textContent).not.toContain('All hosts')
})
})
describe('AutomationHostPicker searchable variant', () => {
it('offers All hosts and every entry, grouped by authority', () => {
render([...manyEntries(9), runtimeSelf('env-1', 'Build box')])
openCommandPicker()
const headings = [...document.querySelectorAll('[cmdk-group-heading]')].map(
(node) => node.textContent
)
expect(headings).toContain('This computer')
expect(headings).toContain('Build box')
expect(document.body.textContent).toContain('All hosts')
expect(visibleHostKeys()).toHaveLength(10)
})
it('lists entries in the shared deterministic catalog order', () => {
const entries = [...manyEntries(9), runtimeSelf('env-1', 'Build box')]
const shuffled = [
entries[4],
entries[9],
entries[0],
...entries.slice(1, 4),
...entries.slice(5, 9)
]
render(shuffled)
openCommandPicker()
expect(visibleHostKeys()).toEqual(
orderAutomationHostCatalogEntries(entries).map((entry) => entry.stableKey)
)
})
it('filters the list by the search query', () => {
render(manyEntries(12))
openCommandPicker()
expect(visibleHostKeys()).toHaveLength(12)
typeSearch('web-03')
expect(visibleHostKeys()).toEqual(['host:desktop:ssh:t3'])
})
it('reports no match without claiming the hosts are gone', () => {
render(manyEntries(12))
openCommandPicker()
typeSearch('nothing-matches-this')
expect(visibleHostKeys()).toEqual([])
expect(document.querySelector('[data-slot="command-empty"]')?.textContent).toBe(
'No hosts match'
)
})
it('reports the chosen host as a stable filter', () => {
const onSelect = vi.fn()
render(manyEntries(9), onSelect)
openCommandPicker()
const item = document.querySelector('[data-host-stable-key="host:desktop:ssh:t3"]')
act(() => {
item?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onSelect).toHaveBeenCalledWith({
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't3' } }
})
})
it('focuses search on open so the keyboard path starts in the field', () => {
render(manyEntries(9))
openCommandPicker()
expect(document.activeElement).toBe(document.querySelector('[data-slot="command-input"]'))
})
it('closes on Escape without changing the selection', () => {
const onSelect = vi.fn()
render(manyEntries(9), onSelect)
openCommandPicker()
expect(document.querySelector('[data-slot="command-input"]')).not.toBeNull()
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(document.querySelector('[data-slot="command-input"]')).toBeNull()
expect(onSelect).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,74 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import type { AutomationHostFilter } from '../../../../shared/automation-host-filter'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
import { AutomationHostPickerCommand } from './AutomationHostPickerCommand'
import { AutomationHostPickerSelect } from './AutomationHostPickerSelect'
/**
* The `Filter by host` control. Presentational: it takes the catalog and the
* resolved filter and reports selections back — it never reads a store or fetches.
*/
/** Design doc: Select at eight or fewer host entries, searchable Command/Popover at nine or more. */
export const AUTOMATION_HOST_PICKER_SELECT_MAX_ENTRIES = 8
export type AutomationHostPickerProps = {
entries: readonly AutomationHostCatalogEntry[]
resolution: AutomationHostFilterResolution
onSelect: (filter: AutomationHostFilter) => void
/** Overrides the Select/Command switch point; production callers leave this alone. */
selectMaxEntries?: number
disabled?: boolean
className?: string
}
function triggerLabelFor(resolution: AutomationHostFilterResolution): string {
if (resolution.effective.kind === 'all') {
return translate('auto.components.automations.hostPicker.allHosts', 'All hosts')
}
return (
resolution.entry?.label ??
// A retained-but-unhydrated selection must not render as All hosts.
translate('auto.components.automations.hostPicker.loadingHost', 'Loading host…')
)
}
export function AutomationHostPicker({
entries,
resolution,
onSelect,
selectMaxEntries = AUTOMATION_HOST_PICKER_SELECT_MAX_ENTRIES,
disabled,
className
}: AutomationHostPickerProps): React.JSX.Element {
const pickerLabel = translate('auto.components.automations.hostPicker.label', 'Filter by host')
const selectedStableKey =
resolution.effective.kind === 'all' ? null : (resolution.entry?.stableKey ?? null)
if (entries.length <= selectMaxEntries) {
return (
<AutomationHostPickerSelect
entries={entries}
selectedStableKey={selectedStableKey}
triggerLabel={triggerLabelFor(resolution)}
pickerLabel={pickerLabel}
disabled={disabled}
onSelect={onSelect}
className={className}
/>
)
}
return (
<AutomationHostPickerCommand
entries={entries}
selectedStableKey={selectedStableKey}
triggerLabel={triggerLabelFor(resolution)}
pickerLabel={pickerLabel}
disabled={disabled}
onSelect={onSelect}
className={className}
/>
)
}
@@ -0,0 +1,132 @@
import React, { useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { AutomationHostFilter } from '../../../../shared/automation-host-filter'
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import {
ALL_HOSTS_OPTION_VALUE,
automationHostFilterForEntry,
groupAutomationHostEntriesByAuthority
} from './automation-host-picker-groups'
/**
* Searchable host picker for nine or more entries (design doc, UX section).
* Search focuses on open, Enter selects, Esc closes without changing selection.
*/
export type AutomationHostPickerCommandProps = {
entries: readonly AutomationHostCatalogEntry[]
selectedStableKey: string | null
triggerLabel: string
pickerLabel: string
disabled?: boolean
onSelect: (filter: AutomationHostFilter) => void
className?: string
}
export function AutomationHostPickerCommand({
entries,
selectedStableKey,
triggerLabel,
pickerLabel,
disabled,
onSelect,
className
}: AutomationHostPickerCommandProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const groups = groupAutomationHostEntriesByAuthority(entries)
const choose = (filter: AutomationHostFilter): void => {
onSelect(filter)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
aria-label={pickerLabel}
data-picker-variant="command"
disabled={disabled}
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
>
<span className="truncate">{triggerLabel}</span>
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={4} className="w-[min(22rem,calc(100vw-1rem))] p-0">
<Command>
<CommandInput
autoFocus
placeholder={translate(
'auto.components.automations.hostPicker.searchPlaceholder',
'Search hosts'
)}
aria-label={pickerLabel}
className="h-9 text-xs"
/>
<CommandList className="scrollbar-sleek max-h-72">
<CommandEmpty className="py-4 text-xs text-muted-foreground">
{translate('auto.components.automations.hostPicker.noHostMatch', 'No hosts match')}
</CommandEmpty>
<CommandGroup>
<CommandItem
value={ALL_HOSTS_OPTION_VALUE}
onSelect={() => choose({ kind: 'all' })}
className="text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
selectedStableKey === null ? 'opacity-70' : 'opacity-0'
)}
/>
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
</CommandItem>
</CommandGroup>
{groups.map((group) => (
<CommandGroup key={group.authorityKey} heading={group.authorityLabel}>
{group.entries.map((entry) => (
<CommandItem
// Why: cmdk filters on `value`, so the searchable text is the value itself.
key={entry.stableKey}
value={`${entry.label} ${entry.authorityLabel} ${entry.stableKey}`}
onSelect={() => choose(automationHostFilterForEntry(entry))}
className="gap-2 text-xs"
data-host-stable-key={entry.stableKey}
>
<Check
className={cn(
'size-3 text-muted-foreground',
selectedStableKey === entry.stableKey ? 'opacity-70' : 'opacity-0'
)}
/>
<AutomationHostLabel entry={entry} className="min-w-0 flex-1" />
<AutomationHostStatusBadges entry={entry} />
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AutomationHostFilter } from '../../../../shared/automation-host-filter'
import { AutomationHostPickerSelect } from './AutomationHostPickerSelect'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function sshEntry(targetId: string, label: string): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } },
owner: null,
stableKey: `host:desktop:ssh:${targetId}`,
label,
authorityLabel: 'This computer',
kind: 'ssh',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped'
}
}
function runtimeSelf(environmentId: string, label: string): AutomationHostCatalogEntry {
return {
stableRef: { authority: { kind: 'runtime', environmentId }, selector: { kind: 'self' } },
owner: null,
stableKey: `host:runtime:${environmentId}:self`,
label,
authorityLabel: label,
kind: 'self',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped'
}
}
function render(
entries: readonly AutomationHostCatalogEntry[],
onSelect: (filter: AutomationHostFilter) => void = () => undefined
): void {
act(() => {
root.render(
<TooltipProvider>
<AutomationHostPickerSelect
entries={entries}
selectedStableKey={null}
triggerLabel="All hosts"
pickerLabel="Filter by host"
onSelect={onSelect}
/>
</TooltipProvider>
)
})
}
/** Radix opens on the documented keyboard path, so no pointer emulation is needed. */
function openWithKeyboard(): void {
const trigger = container.querySelector('[data-picker-variant="select"]')
act(() => {
trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
}
function groups(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[data-slot="select-group"]')]
}
function accessibleGroupName(group: HTMLElement): string | null {
const labelledBy = group.getAttribute('aria-labelledby')
if (!labelledBy) {
return null
}
return document.getElementById(labelledBy)?.textContent ?? null
}
describe('AutomationHostPickerSelect authority grouping', () => {
it('groups entries under a real accessible group name, not a visual heading', () => {
render([sshEntry('t0', 'web-00'), runtimeSelf('env-1', 'Build box')])
openWithKeyboard()
const rendered = groups()
expect(rendered.length).toBe(2)
for (const group of rendered) {
expect(group.getAttribute('role')).toBe('group')
}
expect(rendered.map(accessibleGroupName)).toEqual(['This computer', 'Build box'])
})
it('scopes each host row to its own authority group', () => {
render([sshEntry('t0', 'web-00'), runtimeSelf('env-1', 'Build box')])
openWithKeyboard()
// The label inside each row repeats the key, so match the row element itself.
const keysByGroup = groups().map((group) =>
[...group.querySelectorAll('[data-slot="select-item"][data-host-stable-key]')].map((node) =>
node.getAttribute('data-host-stable-key')
)
)
expect(keysByGroup).toEqual([['host:desktop:ssh:t0'], ['host:runtime:env-1:self']])
})
it('keeps All hosts outside every authority group', () => {
render([sshEntry('t0', 'web-00')])
openWithKeyboard()
const allHosts = [...document.querySelectorAll('[data-slot="select-item"]')].find((node) =>
node.textContent?.includes('All hosts')
)
expect(allHosts).toBeDefined()
expect(allHosts?.closest('[data-slot="select-group"]')).toBeNull()
})
it('names the group once instead of repeating the authority on every row', () => {
render([sshEntry('t0', 'web-00'), sshEntry('t1', 'web-01')])
openWithKeyboard()
const rows = [...document.querySelectorAll('[data-host-stable-key]')]
const authorityMentions = rows.filter((row) =>
row.textContent?.includes('This computer')
).length
expect(authorityMentions).toBe(0)
expect(groups().map(accessibleGroupName)).toEqual(['This computer'])
})
it('reports the chosen host as a stable filter', () => {
const onSelect = vi.fn()
render([sshEntry('t0', 'web-00'), sshEntry('t1', 'web-01')], onSelect)
openWithKeyboard()
const row = document.querySelector('[data-host-stable-key="host:desktop:ssh:t1"]')
act(() => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onSelect).toHaveBeenCalledWith({
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }
})
})
})

Some files were not shown because too many files have changed in this diff Show More